Rozliczenie zaległością (kredyt na portfelu)
Rozliczenie zaległością pozwala zamknąć płatność za zajęcia lub rezerwację bez przyjmowania pieniędzy. Kwota trafia na portfel klienta jako saldo ujemne, a klient reguluje ją później — najczęściej raz na koniec miesiąca.
Funkcja powstała dla trenerów, którzy rezerwują korty przez cały miesiąc i płacą zbiorczo. Dlatego nie jest dostępna dla wszystkich — trzeba ją włączyć konkretnemu klientowi.
Nie należy jej mylić z blokadą zapisu przy zaległościach: tamta reaguje na nieopłacone płatności po terminie, ta jest świadomie udzielonym kredytem.
👤 Instrukcja dla pracownika
Włączenie klientowi
Ścieżka: Dashboard ➔ Klienci ➔ (klient) ➔ Edycja ➔ Ustawienia ➔ Zaległość
- Włącz przełącznik „Zezwól na rozliczenie zaległością".
- Opcjonalnie wpisz maksymalną zaległość — to limit łącznego minusa na portfelu, a nie pojedynczej operacji.
- Puste pole oznacza limit domyślny dla klubu.
- Pod limitem dodaj usługi na dokumencie przy doładowaniu — bez nich klient nie będzie miał czym uregulować zaległości.
Bez zaznaczenia tej opcji przycisk „Zaległość" w oknie rozliczenia w ogóle się nie pojawia.
Limit domyślny dla klubu
Ścieżka: Dashboard ➔ Ustawienia ➔ Ograniczenia klientów ➔ Domyślny limit zaległości
Ustawiany osobno dla każdego miasta. Obowiązuje klientów, którzy mają włączoną
zaległość i nie mają własnego limitu. Wartość 0 oznacza brak limitu kwotowego.
Rozliczenie przy ladzie
Ścieżka: Dashboard ➔ Grafik ➔ (zajęcia lub rezerwacja) ➔ Rozlicz
Obok metod „Gotówka", „Karta" i „Portfel" pojawia się przycisk „Zaległość". Pod przyciskami widać, ile limitu klientowi jeszcze zostało. Przycisk jest nieaktywny, gdy kwota do rozliczenia przekracza pozostały limit.
Po kliknięciu:
- płatność zostaje oznaczona jako opłacona, metodą „Zaległość" i znika z listy nieopłaconych,
- saldo portfela klienta maleje o tę kwotę i może zejść poniżej zera,
- nie powstaje żaden dokument — paragon ani faktura — bo pieniądze nie wpłynęły,
- kwota nie wchodzi do utargu ani do przychodu.
Co widzi klient
Portfel w aplikacji pokazuje saldo ujemne na czerwono, a w historii portfela pojawia się wpis typu Zaległość z nazwiskiem uczestnika.
Spłata
Klient nie „opłaca zaległości" osobno — doładowanie portfela automatycznie zmniejsza minus, bo dług jest po prostu ujemnym saldem. Wpłata 500 zł przy saldzie −300 zł daje +200 zł.
Gdzie sprawdzić, kto zalega
Ścieżka: Dashboard ➔ Zaległe płatności
Nad zwykłą listą nieopłaconych płatności jest sekcja „Zaległości na portfelu" z klientami, którzy mają ujemne saldo: kwota do uregulowania, limit i data ostatniego obciążenia. Sekcja pokazuje się tylko wtedy, gdy ktoś rzeczywiście ma minus.
W raporcie finansowym rozliczenia zaległością są w osobnej pozycji „Rozliczone na konto" i można je filtrować metodą płatności „Zaległość".
Wpływ na zapisy
Klient, który wyczerpał swój limit, jest traktowany jak klient z zaległością —
recepcja nie dopisze go na zajęcia, dopóki nie ureguluje części długu. Klient z limitem
0 (bez limitu) nie jest blokowany nigdy.
Wpływ na rezerwacje kortów
Klient z włączoną zaległością nie płaci z góry za rezerwację kortu. Termin jest potwierdzony od razu — nie dostaje blokady na czas płatności, nie przepada po kilkunastu minutach i nie idzie do niego SMS z linkiem do bramki. Należność powstaje normalnie i czeka na liście nieopłaconych płatności, gdzie recepcja rozlicza ją przyciskiem „Zaległość" (albo klient dopłaca ją później doładowaniem portfela).
Tak działają wszystkie trzy ścieżki: rezerwacja klienta w aplikacji, rezerwacja zakładana przy ladzie i seria cykliczna.
Wyjątki — sytuacje, w których klient z zaległością mimo wszystko płaci z góry:
| Sytuacja | Dlaczego |
|---|---|
| Klient ma ustawione „Zawsze wymagaj płatności z góry" (Ustawienia klienta) | To ustawienie odpowiada wprost na to samo pytanie dla tego samego klienta, więc wygrywa z kredytem |
| Klient jest zablokowany za zaległości po terminie | Blokada za przeterminowane płatności to co innego niż udzielony kredyt i zaległość jej nie zdejmuje |
Limit zaległości nie jest tu sprawdzany. Rezerwacja kortu i tak nie obciąża portfela
w momencie zakładania — obciążenie następuje dopiero przy rozliczeniu, i dopiero tam
debitWalletAsArrears pilnuje limitu. Kortów nie pilnuje też
blokada zapisu — celowo, bo klub sprzedaje korty każdemu,
kto wejdzie. Backstopem pozostaje zawieszenie za zaległości po terminie: gdy klient je
zbierze, wraca do płatności z góry.
🛠️ Dokumentacja techniczna
Model danych
Migracja 0262_wallet_arrears.sql:
wallet_transaction.transaction_typeprzyjmuje nową wartość'arrears'(przebudowa tabeli — SQLite nie pozwala zmienićCHECKw miejscu),client_settings.allow_arrears(0/1) orazclient_settings.max_arrears_amount(REAL,NULL= limit klubu),- limit domyślny klubu żyje w
app_settingspod kluczemmax_arrears_amount_default(per miasto), więc nie wymagał migracji.
Dlaczego status to paid_wallet, a nie osobny status
Rozliczenie zaległością zapisuje się jako status = 'paid_wallet' z
payment_method = 'arrears'. To celowe — ten status daje za darmo trzy zachowania,
których osobny status wymagałby zaimplementowania od nowa w kilkudziesięciu miejscach:
| Efekt | Skąd wynika |
|---|---|
| Nie powstaje dokument | processPaymentStatusChange generuje dokumenty tylko dla paid, paid_cash, paid_card, paid_online, paid_mixed |
| Nie wchodzi do utargu | getCashBalance liczy wyłącznie cash, card i mixed |
| Płatność jest „rozliczona" wszędzie indziej | paid_wallet należy do SETTLED_PAYMENT_STATUSES |
Metoda arrears jest jedynym rozróżnieniem między długiem a realną zapłatą z portfela.
Rozpoznają ją:
getDisplayStatus— zwraca sztuczny statuspaid_arrears, żeby etykieta brzmiała „Rozliczone zaległością", a nie „Opłacone portfelem",transformPaymentToTransactionwlib/actions/finances.ts— kwalifikuje wpis do osobnego kubełkaarrears, zanim sprawdzipaid_wallet.
Tabela payment nie ma już CHECK na status ani payment_method (zdjęte w
migracji 0137), więc nowa metoda nie wymagała zmiany schematu.
Kluczowe funkcje
getArrearsAllowance(userEmail) zwraca { allowed, limit, balance, available }.
Dialog rozliczenia woła ją zamiast getUserWallet — saldo jest już w odpowiedzi, więc
okno robi jedno zapytanie do serwera zamiast dwóch.
Limit jest sprawdzany dwa razy
Raz w JS (żeby dać czytelny błąd) i raz w samym UPDATE:
UPDATE wallet SET balance = balance - ?
WHERE id = ? AND tenant_id = ? AND (balance - ?) >= ?
Ostatni parametr to -limit. Bez tego dwie osoby przy dwóch stanowiskach mogłyby
jednocześnie przejść walidację na tym samym, nieaktualnym saldzie. Gdy UPDATE nie
trafi w żaden wiersz, operacja zwraca ARREARS_LIMIT_EXCEEDED. Przy limicie 0
(bez limitu) warunek nie jest dokładany.
debitWalletAsArrears celowo nie korzysta z addWalletTransaction — tamta funkcja
odrzuca każde obciążenie, którego saldo nie pokrywa (WHERE balance >= ?), czyli
dokładnie to zabezpieczenie, które tu trzeba ominąć.
Ujemne saldo w odczytach
getUserWallet i getUserWalletBalances przestały obcinać saldo do zera
(Math.max(0, …)), bo minus musi być widoczny. Obcięcie zostało tylko tam, gdzie
saldo oznacza siłę nabywczą — w calculateWalletDiscount. Ścieżki płatności portfelem
(payWithWallet, deductFromWallet, przyciski „Zapłać z portfela") i tak sprawdzają
balance >= amount albo balance > 0, więc klient na minusie nie zapłaci portfelem.
Rezerwacje kortów bez holdu
requiresImmediateCourtPayment (lib/utils/immediate-payment.ts) rozstrzyga to w
jednym miejscu dla wszystkich trzech ścieżek rezerwacji. Kolejność reguł jest
znacząca:
allow_arrears stoi poniżej require_online_payment: obie opcje włączone naraz to
konfiguracja sprzeczna, a rozstrzyga ta, która odpowiada wprost na pytanie o płatność za
rezerwację. Stoi też poniżej hasArrears, bo zawieszenie za przeterminowane płatności
to nie jest ten sam dług co udzielony kredyt.
Flagę podaje getClientSettingsForUser (lib/utils/client-pricing.ts) — dołożona do
tego samego SELECT, którym rezerwacja i tak czyta nadpisania cenowe, więc ścieżka
rezerwacji nie robi ani jednego zapytania więcej.
Formularze czytają ten sam predykat przez akcję getCourtPaymentPolicy
(lib/actions/client-settings.ts), żeby żółte ostrzeżenie „wymaga natychmiastowej
płatności" nie obiecywało okna, którego rezerwacja nie dostanie:
components/schedule/court-booking-dialog.tsx— politykę dostaje propem ze strony/dashboard/reservations(dialog zawsze rezerwuje dla zalogowanego konta),ReservationForm.tsx— dociąga ją dla wybranego uczestnika, bo przy ladzie sesja należy do recepcji.
getCourtPaymentPolicy zwraca wyłącznie te dwa pola i honoruje podany adres tylko dla
ADMIN/BACKOFFICE — jest wywoływalna z przeglądarki, a nadpisania cenowe recepcji nie
mają czego szukać w oknie rezerwacji klienta.
Blokada zapisów
findPlayersWithExhaustedArrearsLimit w lib/enrollment-debt-guard.ts dokłada do
blokady uczestników, których opiekun ma balance + limit <= 0. Działa niezależnie
od przełącznika „Blokuj zapis przez pracownika przy zaległościach" — limit kredytowy
obowiązuje niezależnie od tego, czy klub blokuje zapisy przy zwykłych zaległościach po
terminie.
Testy
__tests__/lib/actions/wallet-arrears.test.ts— zgoda, limity, wyścig na saldzie, trzy scenariusze z ticketu AP-437 (saldo 0, nadpłata większa i mniejsza od kwoty),__tests__/lib/enrollment-debt-guard.test.ts— blokada po wyczerpaniu limitu i brak blokady przy kredycie bez limitu,lib/utils/immediate-payment.test.ts— kolejność reguł predykatu,lib/actions/booking.arrearsReservationHold.test.ts—createAdminReservationpotwierdza rezerwację klienta z kredytem zamiast ją blokować (AP-999).