Kody rabatowe
Przewodnik po tworzeniu i obsłudze kodów promocyjnych (rabatowych) działających zarówno w płatnościach online (Portal Klienta / Przelewy24), jak i stacjonarnych (recepcja).
👤 Instrukcja dla pracownika (Administracja / Recepcja)
Kody rabatowe pozwalają obniżyć kwotę do zapłaty za zajęcia, rezerwacje, półkolonie czy Weekend z tenisem — kwotowo (np. −29 zł) lub procentowo (np. −20%). Każdy kod może być ograniczony czasowo, limitem użyć, kanałem sprzedaży oraz kwotą minimalną zamówienia.
1. Tworzenie i edycja kodu
Ścieżka dla pracownika: Dashboard ➔ Panel administratora ➔ Kody rabatowe ➔ Utwórz kod
Podczas tworzenia kodu definiujesz:
- Kod – ciąg wpisywany przez klienta (np.
LATO2026). Wielkość liter nie ma znaczenia — system normalizuje kod do wielkich liter. - Typ rabatu – Kwotowy (stała kwota w zł) lub Procentowy (procent wartości zamówienia, maks. 100%).
- Wartość – kwota lub procent.
- Ważność (od / do) – opcjonalne daty startu i wygaśnięcia. Brak dat oznacza kod „zawsze aktywny".
- Limit użyć – maksymalna liczba transakcji, w których kod może zostać wykorzystany (puste = bez limitu).
- Kanały – czy kod działa online, stacjonarnie, czy w obu miejscach.
- Kwota minimalna – minimalna wartość zamówienia, od której kod zadziała.
- Jedno użycie na klienta – jeśli włączone, dany klient (po adresie e-mail) może użyć kodu tylko raz.
- Aktywny – przełącznik szybkiego włączania/wyłączania kodu bez usuwania.
2. Korzystanie z kodu
- Online (Portal Klienta): klient wpisuje kod na ekranie podsumowania płatności; po weryfikacji kwota do zapłaty zostaje obniżona, a różnica trafia do Przelewów24 (lub jest zerowana przy rabacie 100%).
- Stacjonarnie (recepcja): pracownik podaje kod przy rozliczaniu płatności (gotówka/karta). Rabat zostaje rozdzielony pomiędzy pozycje rozliczanej transakcji.
Rabat 100%. Kod pokrywający całą kwotę powoduje, że transakcja jest automatycznie rozliczana (status „opłacone") bez generowania paragonu/faktury — system nie drukuje ani nie fiskalizuje dokumentów na kwotę 0 zł. Taka transakcja nie zwiększa przychodu w module Finanse — patrz Raporty finansowe.
3. Panel kodów rabatowych — co oznaczają liczby
Górne karty podsumowania:
| Karta | Znaczenie |
|---|---|
| Łącznie kodów | Wszystkie zapisane kody. |
| Aktywne | Kody aktualnie dostępne do użycia. |
| Zaplanowane | Kody z datą startu w przyszłości. |
| Wygasłe | Kody po dacie ważności. |
| Użycia | Łączna liczba transakcji, w których wykorzystano kody. |
| Przyznane rabaty | Łączna wartość udzielonych rabatów (w zł). |
W tabeli kolumna „Użyto X z N" pokazuje liczbę transakcji, w których wykorzystano dany kod, względem limitu.
Ważne — liczenie „per transakcja". Jedna transakcja jest liczona jako jedno użycie kodu, nawet jeśli składa się z kilku pozycji/płatności (np. rezerwacja + druga pozycja albo kilku uczestników). Klient płacący jednym kodem za zamówienie z dwiema pozycjami zużywa jedno użycie, a nie dwa.
4. Dlaczego kod nie działa? (komunikaty walidacji)
| Powód | Znaczenie / co zrobić |
|---|---|
| Nie znaleziono | Błędny kod lub literówka. |
| Nieaktywny | Kod wyłączony przełącznikiem „Aktywny". |
| Niedostępny dla tego kanału | Kod ograniczony do online albo stacjonarnie. |
| Jeszcze nieaktywny | Data startu w przyszłości. |
| Wygasł | Po dacie ważności. |
| Limit wyczerpany | Osiągnięto limit liczby transakcji. |
| Kwota poniżej minimum | Wartość zamówienia niższa niż kwota minimalna kodu. |
| Już wykorzystany przez klienta | Kod „jedno użycie na klienta" był już użyty przez tego klienta. |
🛠️ Dokumentacja techniczna
Logika kodów rabatowych znajduje się w lib/actions/discount-codes.ts, a punkty zastosowania w trasach płatności (app/api/payments/*) oraz w akcjach płatności (lib/actions/payment.ts).
Model danych
| Tabela | Rola |
|---|---|
discount_code | Definicja kodu: code, description, discount_type (fixed / percentage), discount_value, start_at, end_at, usage_limit, usage_count, is_active, applies_online, applies_in_club, minimum_amount, allow_multiple_uses_per_user, last_used_at, tenant_id. |
discount_code_usage | Pojedynczy zapis wykorzystania, na płatność: discount_code_id, session_id, payment_id, amount_before, discount_amount, amount_after, tenant_id. |
payment_transaction | Nagłówek transakcji (sesji) z polami session_id, discount_code_id, discount_code, discount_amount, original_amount, total_amount. |
payment | Pojedyncza płatność; współdzieli session_id z innymi płatnościami tej samej transakcji. |
Kolumna discount_code.usage_count jest pochodną (cache) — odzwierciedla liczbę odrębnych transakcji i jest przeliczana, nie inkrementowana na ślepo (patrz niżej).
Walidacja
validateDiscountCode (przez getDiscountCodeByCode) sprawdza kolejno: istnienie, is_active, zgodność kanału (applies_online / applies_in_club), start_at/end_at, usage_limit vs usage_count, minimum_amount, oraz — przy allow_multiple_uses_per_user = 0 — wcześniejsze użycie przez tego klienta. Zwraca jeden z powodów: not_found, inactive, not_applicable_for_channel, not_started, expired, usage_limit_reached, amount_below_minimum, already_used_by_user, invalid_parameters.
Obliczanie i rozdzielanie rabatu
Kwota rabatu liczona jest w validateDiscountCode:
percentage: discountAmount = amount * discount_value / 100
fixed: discountAmount = discount_value
clamp: discountAmount = min(discountAmount, amount) // nigdy poniżej 0 zł
Dla transakcji wielopłatnościowej rabat jest rozdzielany na poszczególne płatności proporcjonalnie do ich kwoty przez allocateDiscountAcrossPayments (lib/utils/discount-allocation.ts), z rozliczeniem reszty w groszach. Każda płatność otrzymuje własny wpis discount_code_usage z amount_before / discount_amount / amount_after.
Dlaczego wierszy per-płatność nie scalamy.
mapPaymentDTOTosPayments(lib/actions/payment.ts) czytadiscount_code_usagepopayment_id, aby wyliczyćamount_after_discountkażdej płatności. Te wartości zasilają paragony/faktury oraz przychód w Finansach, więc wpisy per-płatność muszą pozostać.
Rejestracja wykorzystania
recordDiscountCodeUsage wstawia wiersz do discount_code_usage i aktualizuje discount_code. Jest idempotentna: jeśli istnieje już wpis dla danego session_id lub payment_id, kończy bez zmian. Wywoływana z czterech miejsc:
| Miejsce | Klucz | Kontekst |
|---|---|---|
app/api/payments/initialize/route.ts | sessionId | inicjacja płatności online |
app/api/payments/webhook/route.ts | sessionId | potwierdzenie płatności (P24) |
lib/actions/payment.ts → updatePaymentStatus | paymentId | rozliczenie pojedynczej płatności (stacjonarnie) |
lib/actions/payment.ts → bulkUpdatePaymentStatus | paymentId (na alokację) | rozliczenie transakcji wielopłatnościowej |
Liczenie użycia „per transakcja" (poprawka AP-560)
Problem. Jedno użycie kodu rozbite na kilka płatności tworzyło wiele wierszy discount_code_usage i — wcześniej — inkrementowało usage_count o +1 za każdą płatność. Raportowanie liczyło COUNT(*) wierszy, więc kod użyty realnie 3 razy mógł pokazywać 6.
Rozwiązanie (warstwa odczytu + licznik, bez usuwania wierszy):
Klucz transakcji (stała REDEMPTION_KEY_SQL w lib/actions/discount-codes.ts):
COALESCE(dcu.session_id, p.session_id, 'p' || dcu.payment_id, 'u' || dcu.id)
Płatności jednej transakcji współdzielą session_id, więc grupują się do jednego klucza; online używa dcu.session_id, a izolowana płatność spada na payment_id/id.
-
Raportowanie (
getDiscountCodeSummary,listDiscountCodes,fetchDiscountCodeRow) liczyCOUNT(DISTINCT <klucz transakcji>)zamiastCOUNT(*). -
Licznik w
recordDiscountCodeUsagejest przeliczany jako liczba odrębnych transakcji, zamiastusage_count = usage_count + 1:UPDATE discount_codeSET usage_count = (SELECT COUNT(DISTINCT COALESCE(dcu.session_id, p.session_id, 'p' || dcu.payment_id, 'u' || dcu.id))FROM discount_code_usage dcuLEFT JOIN payment p ON p.id = dcu.payment_idWHERE dcu.discount_code_id = ? AND dcu.tenant_id = ?)WHERE id = ? AND tenant_id = ?Dzięki temu kolumna pozostaje poprawna nawet przy wywołaniach per-płatność, co naprawia również egzekwowanie limitów wszędzie, gdzie czytany jest
usage_count(walidacja kodu oraz limity w Półkoloniach i Weekendzie z tenisem —lib/actions/camp-discounts.ts,lib/actions/tennis-course-discounts.ts). -
Migracja
migrations/0162_recompute_discount_code_usage_count.sqlprzeliczausage_countdla istniejących kodów (wielodzierżawcowo, idempotentnie). Wymagayarn db:update.
Sumy kwot rabatów (SUM(discount_amount)) pozostają bez zmian — pieniądze liczone są poprawnie po stronie wszystkich wierszy.
Wpływ na przychód w Finansach (poprawka AP-560)
W lib/actions/finances.ts kwota transakcji bazuje na amount_after_discount ?? amount (zamiast brutto amount), więc płatność pokryta kodem w 100% nie zawyża przychodu ani rozbicia wg metod płatności. Szczegóły: Raporty finansowe.