Skip to main content

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ść

  1. Włącz przełącznik „Zezwól na rozliczenie zaległością".
  2. Opcjonalnie wpisz maksymalną zaległość — to limit łącznego minusa na portfelu, a nie pojedynczej operacji.
  3. Puste pole oznacza limit domyślny dla klubu.
  4. 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:

SytuacjaDlaczego
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 terminieBlokada 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_type przyjmuje nową wartość 'arrears' (przebudowa tabeli — SQLite nie pozwala zmienić CHECK w miejscu),
  • client_settings.allow_arrears (0/1) oraz client_settings.max_arrears_amount (REAL, NULL = limit klubu),
  • limit domyślny klubu żyje w app_settings pod kluczem max_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:

EfektSkąd wynika
Nie powstaje dokumentprocessPaymentStatusChange generuje dokumenty tylko dla paid, paid_cash, paid_card, paid_online, paid_mixed
Nie wchodzi do utargugetCashBalance liczy wyłącznie cash, card i mixed
Płatność jest „rozliczona" wszędzie indziejpaid_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 status paid_arrears, żeby etykieta brzmiała „Rozliczone zaległością", a nie „Opłacone portfelem",
  • transformPaymentToTransaction w lib/actions/finances.ts — kwalifikuje wpis do osobnego kubełka arrears, zanim sprawdzi paid_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 — createAdminReservation potwierdza rezerwację klienta z kredytem zamiast ją blokować (AP-999).