Skip to main content

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 rabatuKwotowy (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:

KartaZnaczenie
Łącznie kodówWszystkie zapisane kody.
AktywneKody aktualnie dostępne do użycia.
ZaplanowaneKody z datą startu w przyszłości.
WygasłeKody 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ódZnaczenie / co zrobić
Nie znalezionoBłędny kod lub literówka.
NieaktywnyKod wyłączony przełącznikiem „Aktywny".
Niedostępny dla tego kanałuKod ograniczony do online albo stacjonarnie.
Jeszcze nieaktywnyData startu w przyszłości.
WygasłPo dacie ważności.
Limit wyczerpanyOsiągnięto limit liczby transakcji.
Kwota poniżej minimumWartość zamówienia niższa niż kwota minimalna kodu.
Już wykorzystany przez klientaKod „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

TabelaRola
discount_codeDefinicja 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_usagePojedynczy zapis wykorzystania, na płatność: discount_code_id, session_id, payment_id, amount_before, discount_amount, amount_after, tenant_id.
payment_transactionNagłówek transakcji (sesji) z polami session_id, discount_code_id, discount_code, discount_amount, original_amount, total_amount.
paymentPojedyncza 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) czyta discount_code_usage po payment_id, aby wyliczyć amount_after_discount każ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:

MiejsceKluczKontekst
app/api/payments/initialize/route.tssessionIdinicjacja płatności online
app/api/payments/webhook/route.tssessionIdpotwierdzenie płatności (P24)
lib/actions/payment.tsupdatePaymentStatuspaymentIdrozliczenie pojedynczej płatności (stacjonarnie)
lib/actions/payment.tsbulkUpdatePaymentStatuspaymentId (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) liczy COUNT(DISTINCT <klucz transakcji>) zamiast COUNT(*).

  • Licznik w recordDiscountCodeUsage jest przeliczany jako liczba odrębnych transakcji, zamiast usage_count = usage_count + 1:

    UPDATE discount_code
    SET usage_count = (
    SELECT COUNT(DISTINCT COALESCE(dcu.session_id, p.session_id, 'p' || dcu.payment_id, 'u' || dcu.id))
    FROM discount_code_usage dcu
    LEFT JOIN payment p ON p.id = dcu.payment_id
    WHERE 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.sql przelicza usage_count dla istniejących kodów (wielodzierżawcowo, idempotentnie). Wymaga yarn 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.