Dokumentacja techniczna platformy SupportME

Pełny opis systemu moduł po module: co robi każdy element, jakie udostępnia trasy, jakie metody zawiera, jakie ma pola i z których tabel korzysta oraz co dokładnie dzieje się podczas działania. Dokument wygenerowany na podstawie analizy kodu źródłowego.

Aktualizacja: 1 sierpnia 2026 · Laravel 12 · PHP 8.2+ · architektura multi-tenant

Legenda statusów: Aktywny — działa na produkcji · Demo — zbudowany, tryb pokazowy/testowy · Osobny tenant — pod inną domeną · Gotowy — kod gotowy, włączany konfiguracją. Kwoty pieniężne w bazie przechowywane są w groszach (liczby całkowite).

1.

Architektura multi-tenant

Aktywny

Jedna aplikacja Laravel obsługuje kilka niezależnych serwisów. To, który moduł i która baza danych obsłużą żądanie, zależy od hostu (domeny). Rozstrzyga to middleware ResolveTenant, uruchamiany jako pierwszy — przed sesją i tokenem CSRF — bo musi ustawić bazę i kontekst zanim cokolwiek innego się wykona.

Mapowanie hostów → tenant
Host (domena)ModułTrybBazaRola
pay.please-support-me.comGatewaynfc_payBramka płatności, panel tagów/sklepów
please-support-me.comStorefrontchurchnfc_shop1Cyfrowa Taca, sklep, CRM, rekrutacja (główny serwis)
Co robi ResolveTenant
  • Czyta host żądania i mapuje go na tenant (moduł + tryb + baza + klucz API bramki).
  • Dynamicznie podmienia bazę połączenia MySQL (config.database.connections.mysql.database) — per żądanie, bez restartu.
  • Przełącza ścieżki widoków Blade zależnie od trybu (church / products), więc ten sam kontroler renderuje inny wygląd.
  • Rejestruje singleton app('tenant') dostępny w całej aplikacji oraz klucz API bramki w config('shop.gateway_api_key').
Połączenia bazy

Połączenie mysql jest przełączane per host (sklepy), a dedykowane połączenie gateway wskazuje zawsze na nfc_pay — modele bramki czytają z niego niezależnie od tego, jaki tenant obsługuje bieżące żądanie.

Pliki
app/Http/Middleware/ResolveTenant.php · config/tenants.php · config/platform.php · config/database.php
2.

Warstwa płatności — przegląd

Aktywny

Płatności obsługuje osobny moduł Gateway (własna baza nfc_pay), z którym sklepy komunikują się przez REST API i webhooki. Sklep nigdy nie rozmawia bezpośrednio z PayU — robi to bramka, co pozwala obsłużyć wiele sklepów jednym, bezpiecznym połączeniem z operatorem. Pełny cykl jest wielowarstwowy: webhook + aktywny polling + podpisy kryptograficzne.

Przepływ end-to-end
  1. Inicjacja. Sklep tworzy transakcję w bramce na wybraną kwotę — POST /api/v1/transactions z nagłówkiem X-Api-Key. Bramka zwraca uuid i payment_url.
  2. Autoryzacja. Klient na stronie bramki płaci BLIK-iem (kod 6-cyfrowy) lub pay-by-link (przekierowanie do aplikacji banku). Bramka tworzy zamówienie w PayU (REST v2.1, OAuth).
  3. Webhook PayU → bramka. PayU wysyła powiadomienie POST /webhooks/payu z nagłówkiem OpenPayu-Signature (weryfikacja MD5/SHA256). Bramka oznacza transakcję jako opłaconą/nieudaną.
  4. Webhook bramka → sklep. Bramka woła notify_url sklepu z podpisem HMAC-SHA256. Sklep weryfikuje podpis i aktualizuje zamówienie (status = paid, paid_at).
  5. Polling (gwarancja). Ekran powrotu odpytuje status; bramka aktywnie rekonsyliuje z PayU (getOrderStatus), a transakcje „oczekujące na potwierdzenie” domyka przez capture().
Tryby płatności

classic — klasyczny przepływ z 3DS; app2app — BLIK / pay-by-link z przekierowaniem do aplikacji bankowej. Tryb ustawiany per sklep w polu payment_mode.

3.

Sklep donacyjny NFC (/)

Aktywny

Strona główna serwisu. Wyświetla produkty oznaczone tagami NFC (Serduszko, Kubek, Koszulka, Pin, Brelok). Każdy produkt ma własną minimalną kwotę. Domyślny produkt („Serduszko”, min. 1 zł) otwiera się automatycznie w modalu po wejściu. Kwota jest edytowalna w modalu, a walidacja minimum działa zarówno w przeglądarce, jak i na serwerze. Obsługiwane przez CompanyStoreController.

Trasy
MetodaURLNazwaOpis
GET/homeLista produktów + modal KUP
POST/sklep/kup/{slug}shop.buyZakup produktu na wybraną kwotę
Metody kontrolera
  • index()Pobiera aktywne produkty (sortowanie po sort), wyznacza domyślny (is_default) i renderuje stronę z siatką oraz danymi produktów dla modala (JSON).
  • purchase($slug)Waliduje kwotę (≥ minimum produktu, ≤ 5000 zł) po stronie serwera, tworzy zamówienie (product_id = null), woła bramkę i przekierowuje do płatności. Komunikat błędu zawiera nazwę produktu i jego minimum.
Pola produktu (model ShopItem → tabela shop_items)
PoleTypOpis
slugstring, unikalnyIdentyfikator URL (np. serduszko)
namestringNazwa produktu
imagestringŚcieżka do grafiki (raster lub SVG serca)
min_amountint (grosze)Minimalna kwota wpłaty (100 = 1 zł)
is_defaultboolCzy produkt domyślny (auto-modal); tylko jeden naraz
tag_uidstring, nullableUID taga NFC kierującego wprost na ten produkt
activeboolWidoczność w sklepie
sortintKolejność na liście
Co się dzieje (przepływ zakupu)
  1. Użytkownik wchodzi na / — domyślny produkt otwiera się w modalu (raz na sesję, przez sessionStorage), albo otwiera się produkt wskazany w ?produkt={slug}.
  2. Klik karty produktu otwiera modal z edytowalnym polem kwoty (wstępnie = minimum produktu).
  3. Walidacja w przeglądarce: przy kwocie poniżej minimum przycisk KUP jest blokowany i pojawia się komunikat.
  4. POST /sklep/kup/{slug} — serwer ponownie waliduje kwotę (warstwa nie do obejścia), tworzy Order i transakcję w bramce.
  5. Przekierowanie 302 na payment_url bramki (PayU).
Tabele
shop_itemsorders
Pliki
CompanyStoreController.php · Models/ShopItem.php · views/…/shop/sklep.blade.php · public/css/sklep.css · public/css/landing.css (modal)
4.

Cyfrowa Taca — wejście NFC i parafie

Aktywny

Główna funkcja platformy. Darczyńca zbliża telefon do znacznika NFC w kościele, trafia na stronę parafii, wybiera kwotę i płaci, a na końcu widzi ekran „Bóg zapłać”. Wszystkim steruje StorefrontController; dane parafii zarządzane są w panelu (sekcja 16). Każdy etap loguje zdarzenie do analityki.

Trasy
MetodaURLNazwaOpis
GET/mainmainLanding (sekcja 5)
GET/kategoria/{slug}categoryLista parafii w kategorii + wyszukiwarka
GET/t/{tag_uid}tagWejście z taga NFC → przekierowanie
GET/p/{slug}product.showStrona parafii + wybór kwoty
POST/p/{slug}/kupproduct.buyUtworzenie zamówienia i transakcji
Metody kontrolera
  • index()Renderuje landing: kategorie wsparcia z bazy (aktywne, najwyższy poziom) z ikoną, etykietą i opisem.
  • category($slug)Pokazuje parafie danej kategorii (gdy source = parishes) lub pusty stan. Po stronie klienta działa wyszukiwarka po nazwie, mieście i województwie.
  • tag($tagUid)Szuka parafii po tag_uid → przekierowuje na jej stronę i loguje tag_open. Jeśli to tag produktu sklepu → przekierowuje na sklep z modalem produktu. Nieznany tag → strona 404 „tabliczka nieprzypisana”.
  • show($slug)Strona parafii; loguje page_view; renderuje formularz wyboru kwoty z presetami.
  • buy($slug)Waliduje kwotę (2–5000 zł), loguje buy_click, tworzy Order powiązany z parafią i transakcję w bramce, przekierowuje do płatności.
Pola parafii (model Product → tabela products)
PoleTypOpis
namestringNazwa parafii
citystringMiasto
purposestringCel zbiórki (np. „Remont dachu”)
slugstring, unikalnyIdentyfikator URL
description_htmltextOpis parafii (WYSIWYG)
priceint (grosze)Sugerowana kwota tacy (preset bazowy)
tag_uidstring, unikalnyUID taga NFC przypisanego do parafii
main_imagestringZdjęcie główne parafii
activeboolPublikacja (sterowana statusem CRM)
phone, website, voivodeshipstringDane kontaktowe (CRM)
statusenumCRM: kontakt · test · wdrożenie · aktywna
salesperson_idFKHandlowiec opiekujący się parafią
Presety kwot (strona parafii)

Domyślne przyciski: 10, 20, 50, 100, 200 zł + kafelek „inna kwota” zamieniający się w pole liczbowe. Zakres dozwolony: 2–5000 zł. Wybrana kwota aktualizuje etykietę przycisku „Wesprzyj — X zł”.

Co się dzieje (pełny przepływ tacy)
  1. Telefon przy tagu NFC otwiera /t/{tag_uid}.
  2. tag() znajduje parafię, loguje tag_open (lokalnie + asynchronicznie do bramki) i przekierowuje na /p/{slug}.
  3. show() loguje page_view i pokazuje stronę z presetami.
  4. Po wyborze kwoty POST /p/{slug}/kup: buy() loguje buy_click, tworzy zamówienie i transakcję, przekierowuje do PayU.
  5. Po płatności następuje powrót na ekran zwrotu (sekcja 10) — „Bóg zapłać”.
Tabele
productscategoriesorderseventsproduct_images
Pliki
StorefrontController.php · Models/Product.php, Category.php, Order.php, Event.php · views/…/shop/{home,category,product}.blade.php · Jobs/SendGatewayEvent.php
5.

Strona główna /main

Aktywny

Landing „Technologia, która pomaga czynić dobro” — zbudowany pixel-perfect z makiet Figmy (desktop i mobile). Sekcje są dynamiczne: kategorie „Kogo wspieramy?” pobierane są z bazy i zarządzane z panelu.

Sekcje
  • Hero — nagłówek z misją platformy.
  • Kogo wspieramy? — kafelki kategorii (ikona, etykieta HTML, opis); linki do /kategoria/{slug}.
  • Jak to działa? — 4 kroki: zbliż telefon do NFC → wybierz wsparcie → zapłać → otrzymaj podziękowanie.
  • Tekst zamykający + stopka z danymi firmy i linkami.
Modal „Wesprzyj” i podgląd linku (OG)

Przycisk „Wesprzyj” w nagłówku otwiera modal domyślnego produktu (/?produkt=serduszko). W <head> ustawione są tagi Open Graph i Twitter z grafiką serca z logo — przy udostępnianiu linku na WhatsApp / Facebook pojawia się ładny podgląd z serduszkiem.

Tabele
categories
Pliki
StorefrontController::index() · views/…/shop/home.blade.php · layouts/landing.blade.php · public/css/landing.css · public/img/og-supportme.png
6.

Inwestorzy i akcjonariusze

Aktywny

Statyczna strona marketingowa (GET /inwestorzy, nazwa trasy investors) zbudowana 1:1 z makiety Figmy. Hero „Inwestorzy i akcjonariusze”, sekcja misji kapitałowej („Wierzymy, że kapitał może służyć dobru”) oraz karty akcjonariuszy z kwotami inwestycji (kapitałowa + wsparcie usługowe). Renderowana bez kontrolera (Route::view).

Pliki
routes/web.php (Route::view) · views/…/shop/inwestorzy.blade.php · public/css/inwestorzy.css
7.

Rekrutacja i kariera

Aktywny

Pełny system rekrutacyjny: publiczna lista ofert (zarządzana z panelu), strony ofert, formularz aplikacji z uploadem CV oraz powiadomienie e-mail z załączonym CV. Obsługiwany przez CareersController. CV trafia na prywatny dysk (niedostępny publicznie), a pobrać je można tylko z panelu.

Trasy
MetodaURLNazwaOpis
GET/pracacareersLista aktywnych ofert
GET/praca/oferta/{position}careers.showSzczegóły oferty
GET/POST/praca/aplikujcareers.apply.generalAplikacja spontaniczna (bez oferty)
GET/POST/praca/{position}/aplikujcareers.applyAplikacja na konkretną ofertę
Metody kontrolera
  • index()Lista aktywnych stanowisk; karty z typem zatrudnienia i lokalizacją; wykrywa „pracę zdalną” z treści.
  • show($position)Pełny opis oferty (WYSIWYG), chipy meta, sekcja „inne oferty”.
  • applyForm($position?)Renderuje formularz aplikacji (na ofertę lub spontaniczny).
  • applyStore($position?)Waliduje dane i plik CV, zapisuje zgłoszenie, przenosi CV na prywatny dysk, wysyła e-mail (jeśli skonfigurowany mailer), pokazuje podziękowanie.
Pola oferty (JobPosition → job_positions)
PoleTypOpis
titlestringNazwa stanowiska
locationstringLokalizacja
employment_typestringRodzaj zatrudnienia
description_htmltextOpis (WYSIWYG)
activeboolWidoczność publiczna
sortintKolejność
Pola zgłoszenia (JobApplication → job_applications)
PoleTypOpis
job_position_idFK, nullableOferta (null = aplikacja spontaniczna)
name, email, phonestringDane kandydata
messagetext, nullableList motywacyjny
cv_pathstringŚcieżka pliku CV na prywatnym dysku
cv_original_namestringOryginalna nazwa pliku
is_readboolCzy odczytane w panelu
statusenumpending · accepted · rejected
Walidacja i e-mail

CV: wymagane, do 5 MB, typy pdf/doc/docx (sprawdzane rozszerzenie i MIME). Zgoda RODO: wymagana. Po zapisie generowany jest mail JobApplicationReceived na config('shop.careers_email') z CV w załączniku i adresem kandydata w Reply-To.

Powiadomienia e-mail Gotowy — kod wysyłki maila jest kompletny; na produkcji działa tryb „tylko panel” (zgłoszenia + CV trafiają do skrzynki w panelu). Wysyłkę mailem włącza się konfiguracją mailera.
Tabele
job_positionsjob_applications
Pliki
CareersController.php · Models/JobPosition.php, JobApplication.php · Mail/JobApplicationReceived.php · views/…/shop/{praca,oferta,aplikuj}.blade.php · views/emails/job-application.blade.php
8.

Kontakt

Aktywny

Publiczny formularz kontaktowy (ContactController). Wiadomości zapisywane są do bazy i trafiają do skrzynki w panelu z licznikiem nieprzeczytanych. Temat może być wstępnie wypełniony z linku z oferty pracy (?stanowisko=…).

Trasy i metody
  • show()GET /kontakt (nazwa contact.show) — formularz.
  • store()POST /kontakt (nazwa contact.store) — walidacja i zapis wiadomości.
Pola (ContactMessage → contact_messages)
PoleTypWalidacja
namestringwymagane, max 255
emailstringwymagane, e-mail, max 255
phonestring, nullablemax 50
subjectstring, nullablemax 255
messagetextwymagane, max 5000
is_readbooloznaczane w panelu
Tabele
contact_messages
Pliki
ContactController.php · Models/ContactMessage.php · views/…/shop/kontakt.blade.php
9.

Regulamin

Aktywny

Statyczny dokument prawny (GET /regulamin, nazwa regulamin) — kompletny regulamin sklepu internetowego przeniesiony 1:1 z dokumentu źródłowego. 12 paragrafów + wzór formularza odstąpienia.

Zawartość
  • §1 Postanowienia ogólne · §2 Definicje · §3 Usługi elektroniczne · §4 Zamówienia
  • §5 Ceny i płatności · §6 Wymagania techniczne · §7 Odstąpienie (14 dni) · §8 Reklamacje i zwroty
  • §9 Ochrona danych (RODO) · §10 Własność intelektualna · §11 ODR · §12 Postanowienia końcowe
  • Załącznik: wzór formularza odstąpienia; dane firmy: Fundacja Support Me Haven & Heaven, NIP 5342715041
Pliki
routes/web.php (Route::view) · views/…/shop/regulamin.blade.php · public/css/subpages.css
10.

Powrót z płatności

Aktywny

Po powrocie z bramki OrderReturnController synchronizuje status zamówienia i pokazuje właściwy ekran: sukces („Bóg zapłać”), oczekiwanie (z pollingiem) lub niepowodzenie (z opcją ponowienia). To zabezpiecza przed sytuacją, gdy klient wróci wcześniej niż dotrze webhook.

Trasy i metody
  • show($order)GET /zwrot/{order} (nazwa order.return) — synchronizuje status z bramki; jeśli opłacone, loguje purchase; renderuje ekran wg statusu.
  • status($order)GET /zwrot/{order}/status (nazwa order.status) — zwraca JSON {status} dla pollingu.
Ekrany (wg statusu zamówienia)
StatusWidokZachowanie
paidreturn-successAnimacja świecy, kwota, nazwa parafii, nr potwierdzenia
pendingreturn-pendingSpinner + polling co 2 s (do ~60 s); po zmianie statusu przeładowanie
failedreturn-failureKomunikat o niepowodzeniu, przyciski „spróbuj ponownie” / „inna parafia”
Pola zamówienia (Order → orders)
PoleTypOpis
idUUIDIdentyfikator zamówienia
product_idFK, nullableParafia (null dla sklepu donacyjnego)
transaction_idUUIDTransakcja w bramce
amountint (grosze)Kwota wpłaty
statusenumpending · paid · failed
paid_attimestamp, nullableCzas potwierdzenia
Tabele
ordersevents
Pliki
OrderReturnController.php · views/…/shop/return-{success,pending,failure}.blade.php
11.

Klient bramki i webhooki (po stronie sklepu)

Aktywny

Sklep komunikuje się z bramką przez serwis GatewayClient oraz odbiera powiadomienia zwrotne (webhooki) o opłaceniu. Zdarzenia analityczne (np. tag_open) wysyłane są asynchronicznie, by nie spowalniać odpowiedzi.

Metody GatewayClient
  • createTransaction($data)POST do API bramki z danymi: product_external_id, product_name, amount, currency, return_url, notify_url, tag_uid. Zwraca uuid i payment_url.
  • getTransaction($uuid)Pobiera bieżący status transakcji (używane przy synchronizacji ekranu zwrotu).
  • sendEvent($type, $tagUid)Wysyła zdarzenie do bramki (np. tag_open).
  • verifyWebhookSignature($payload, $sig)Weryfikuje podpis HMAC-SHA256 przychodzącego webhooka kluczem API sklepu.
Odbiór webhooka

GatewayWebhookController::handle() (POST /webhooks/gateway) weryfikuje podpis, a następnie ustawia Order.status = paid + paid_at i loguje zdarzenie purchase. SendGatewayEvent (job kolejkowy, dispatchAfterResponse) wysyła eventy do bramki już po odesłaniu odpowiedzi do użytkownika.

Pliki
Services/GatewayClient.php · Http/Controllers/GatewayWebhookController.php · Jobs/SendGatewayEvent.php

Po co ten moduł i na co uważać

Klient bramki to jedyny punkt styku sklepu z warstwą płatności. Kluczowa zasada bezpieczeństwa: sklep nigdy nie ufa przekierowaniu z PayU ani redirectowi powrotnemu — status zamówienia ustala wyłącznie po zweryfikowanym webhooku lub po aktywnym odpytaniu API bramki (getTransaction). Dzięki temu nawet jeśli klient zamknie kartę zanim wróci na stronę, opłacone zamówienie i tak zostanie domknięte powiadomieniem.

Na co uważać: webhook wychodzący z bramki jest podpisany HMAC-SHA256 kluczem API sklepu — odrzuć każde żądanie z niepoprawnym lub brakującym podpisem (ryzyko podszycia i sztucznego oznaczania zamówień jako opłacone). Eventy analityczne (tag_open) wysyłamy po odesłaniu odpowiedzi (dispatchAfterResponse), aby nie wydłużać czasu odpowiedzi widzianego przez użytkownika.

Krytyczny kod: kontrakt żądania tworzącego transakcję (z Api/TransactionController::store po stronie bramki) — to dokładnie te pola, które klient sklepu musi wysłać.
// POST /api/v1/transactions — walidacja po stronie bramki (kontrakt dla klienta sklepu)
$data = $request->validate([
    'product_external_id' => ['required', 'string', 'max:255'],
    'product_name'        => ['required', 'string', 'max:255'],
    'amount'              => ['required', 'integer', 'min:1'], // grosze
    'currency'            => ['nullable', 'string', 'in:PLN'],
    'return_url'          => ['required', 'url', 'max:500'],
    'notify_url'          => ['nullable', 'url', 'max:500'],
    'tag_uid'             => ['nullable', 'string', 'max:255'],
]);
// odpowiedź: { "uuid": "...", "payment_url": "https://pay.../pay/{uuid}" }

Dlaczego to ważne: amount jest w groszach (liczba całkowita) — przesłanie „10.00” zamiast „1000” to najczęstszy błąd integracji. currency jest twardo ograniczone do PLN, a return_url musi być realnym URL-em (walidacja url), bo to na niego klient wróci po płatności.

Pola webhooka przychodzącego do sklepu

PoleZa co odpowiadaKolumna (tabela.kolumna)Weryfikacja / źródło
transaction_idUUID transakcji w bramceorders.transaction_idmusi wskazywać istniejące zamówienie
statusWynik płatnościorders.status (enum: pending·paid·failed)ustawiany tylko z webhooka
paid_atCzas zaksięgowania wpłatyorders.paid_at (timestamp, nullable)now() przy paid
signaturePodpis HMAC ładunku— (nagłówek/payload)HMAC-SHA256 kluczem API sklepu
Zrzut: wrzuć do public/img/docs/docs-client.png (np. log webhooka, kod GatewayClient, ekran zamówienia opłaconego) — pojawi się tu automatycznie.
12.

Moduł Gateway — bramka PayU

Aktywny

Samodzielna bramka płatnicza działająca jako odrębna aplikacja (pay.please-support-me.com, baza nfc_pay). Integruje PayU, obsługuje wiele sklepów, tagi NFC, transakcje, zdarzenia i statystyki. Zawiera 15 kontrolerów, własne API, panel oraz warstwę dostawców płatności (provider).

Dostawca płatności — PayUProvider
  • createTransaction()Tworzy zamówienie w PayU (POST /api/v2_1/orders, OAuth client_credentials); zwraca provider_order_id i URL przekierowania.
  • getOrderStatus()Pobiera status zamówienia z PayU (aktywna rekonsyliacja).
  • capture()Domyka płatność oczekującą na potwierdzenie (PUT statusu → COMPLETED).
  • payByLinks()Zwraca listę banków do ekranu wyboru (pay-by-link).
  • handleWebhook()Weryfikuje OpenPayu-Signature i parsuje powiadomienie. Obsługa BLIK Level 0 i pay-by-link.

Interfejs PaymentProviderInterface pozwala podmienić dostawcę; alternatywą jest MockProvider (tryb testowy).

Logika transakcji — TransactionService
  • reconcileWithProvider()Aktywnie sprawdza status u PayU (gdy webhook się spóźnia).
  • markPaid() / markFailed()Idempotentnie ustawia status transakcji.
  • logEvent()Zapisuje zdarzenie transakcji do bazy.
  • notifyShop()Wysyła webhook wychodzący do sklepu, podpisany HMAC-SHA256.
Pola transakcji (Transaction → transactions)
PoleTypOpis
idUUIDIdentyfikator transakcji (= uuid u sklepu)
shop_id, tag_idFKSklep i opcjonalny tag NFC
product_external_idstringIdentyfikator produktu po stronie sklepu
product_namestringNazwa pozycji (np. „Taca — Parafia X”)
amount, currencyint / stringKwota (grosze) i waluta (PLN)
statusenumcreated · pending · paid · failed · abandoned
modestringclassic / app2app
return_url, notify_urlstringPowrót i webhook sklepu
provider_order_idstringID zamówienia w PayU
paid_attimestampCzas opłacenia
Tabele
shopstagstransactionseventsleadsantitheft_checks
Pliki
Modules/Gateway/Payments/{PayUProvider,MockProvider,PaymentProviderInterface,TransactionDto,WebhookResult}.php · Services/{TransactionService,StatsService}.php · Models/{Shop,Tag,Transaction,Event,Lead,AntitheftCheck}.php

Dlaczego osobna bramka i na co uważać

Bramka jest celowo wydzielona jako odrębny tenant z własną bazą nfc_pay i dedykowanym połączeniem Eloquent (protected $connection = 'gateway' w każdym modelu). Dzięki temu dane finansowe są fizycznie odseparowane od danych sklepów, a jedne dane uwierzytelniające PayU obsługują wszystkie sklepy. Token OAuth jest cache'owany (payu_access_token, TTL ~12 h z marginesem 5 min), więc nie autoryzujemy się przy każdej transakcji.

Na co uważać: PayU zwraca utworzenie zamówienia jako HTTP 302 z JSON-em w body — dlatego klient HTTP ma wyłączone podążanie za przekierowaniem (allow_redirects => false). Status sukcesu to nie tylko SUCCESS, ale też WARNING_CONTINUE_3DS i WARNING_CONTINUE_REDIRECT (karty/3DS). Pominięcie tych dwóch zerwałoby płatności kartą.

Krytyczny kod: tworzenie zamówienia w PayU (PayUProvider::createTransaction) — budowa ładunku i interpretacja odpowiedzi 302+JSON.
// PayU REST v2.1 — POST /api/v2_1/orders (Http z allow_redirects=false)
$payload = [
    'merchantPosId' => (string) config('payment.payu.pos_id'),
    'extOrderId'    => $transaction->id,            // UUID = klucz idempotencji
    'customerIp'    => $customerIp ?: '127.0.0.1',
    'description'   => $transaction->product_name,
    'currencyCode'  => $transaction->currency,
    'totalAmount'   => (string) $transaction->amount, // grosze!
    'continueUrl'   => route('pay.return', $transaction->id),
    'notifyUrl'     => route('webhooks.payu'),
    'products' => [['name' => $transaction->product_name,
                    'unitPrice' => (string) $transaction->amount, 'quantity' => '1']],
];
// PayU oddaje 302 z JSON w body — NIE podążamy za redirectem
$statusCode = $data['status']['statusCode'] ?? null;
if (! in_array($statusCode, ['SUCCESS','WARNING_CONTINUE_3DS','WARNING_CONTINUE_REDIRECT'])
    || empty($data['redirectUri'])) { /* log + throw */ }

Dlaczego to ważne: extOrderId = UUID transakcji bramki, więc PayU nie utworzy dwóch zamówień dla tej samej transakcji (idempotencja). totalAmount w groszach jako string — typowy błąd to przeliczenie na złotówki.

Pola transakcji i mapowanie na bazę (Transaction → transactions)

PoleZa co odpowiadaKolumna (tabela.kolumna)Typ / wartości
idUUID transakcji (= uuid u sklepu, = extOrderId w PayU)transactions.iduuid, primary, HasUuids
shop_idSklep, który utworzył transakcjętransactions.shop_idFK → shops, constrained
tag_idTag NFC (jeśli wejście z tagu)transactions.tag_idFK → tags, nullable, nullOnDelete
product_external_idID produktu po stronie skleputransactions.product_external_idstring
product_nameNazwa pozycji (opis w PayU)transactions.product_namestring
amountKwota wpłatytransactions.amountunsignedInteger (grosze), cast int
currencyWalutatransactions.currencychar(3), default PLN
statusStan transakcjitransactions.statusenum: created·pending·paid·failed·abandoned
modeTryb płatności (z konfiguracji sklepu)transactions.modeenum: classic·app2app
return_urlPowrót klienta do skleputransactions.return_urlstring(500)
notify_urlWebhook wychodzący do skleputransactions.notify_urlstring(500), nullable
provider_order_idID zamówienia w PayUtransactions.provider_order_idstring, nullable
provider_redirect_urlURL przekierowania z PayUtransactions.provider_redirect_urlstring(1000), nullable
paid_atCzas zaksięgowaniatransactions.paid_attimestamp, nullable
Zrzut: wrzuć do public/img/docs/docs-gateway.png (np. lista transakcji, diagram stanów, panel PayU) — pojawi się tu automatycznie.
13.

Gateway: API i webhooki

Aktywny

Bramka udostępnia REST API dla sklepów (autoryzacja kluczem) oraz endpointy płatności i webhooki. Wszystkie powiadomienia są podpisywane i weryfikowane kryptograficznie.

Trasy API i płatności
MetodaURLOpis
POST/api/v1/transactionsUtworzenie transakcji (nagłówek X-Api-Key)
GET/api/v1/transactions/{uuid}Status transakcji
POST/api/v1/eventsRejestracja zdarzenia (np. tag_open)
GET/pay/{uuid}Ekran płatności (wybór metody)
POST/pay/{uuid}/confirmPotwierdzenie płatności (BLIK / pay-by-link)
GET/pay/{uuid}/returnPowrót z PayU
POST/webhooks/payuWebhook PayU (OpenPayu-Signature)
Kontrolery
Api/TransactionController.php · Api/EventController.php · PaymentController.php · WebhookController.php · ActivationStatusController.php

Webhooki i podpisy — dlaczego to fundament

Cała pewność, że wpłata faktycznie nastąpiła, opiera się na weryfikacji podpisu. Webhook PayU niesie nagłówek OpenPayu-Signature w formacie sender=...;signature=...;algorithm=MD5;content=DOCUMENT. Podpis liczymy jako hash(body + second_key) wskazanym algorytmem i porównujemy hash_equals (porównanie odporne na timing attack). Po poprawnej weryfikacji bramka zawsze odpowiada 200 — w przeciwnym razie PayU ponawia powiadomienie.

Na co uważać: nie polegamy wyłącznie na webhooku. Endpoint statusu (GET /api/v1/transactions/{uuid}) przy każdym odpytaniu robi aktywną rekonsyliację z PayU (reconcileWithProvider) — gdy webhook się spóźni, status i tak zostanie domknięty. Statusy PayU mapujemy zachowawczo: tylko COMPLETED → opłacone, CANCELED → nieudane, reszta (PENDING, WAITING_FOR_CONFIRMATION) jest ignorowana.

Krytyczny kod: weryfikacja podpisu webhooka PayU (PayUProvider::verifySignature + mapowanie statusu) — bez tego każde żądanie mogłoby fałszywie oznaczyć transakcję jako opłaconą.
// OpenPayu-Signature: "sender=...;signature=...;algorithm=MD5;content=DOCUMENT"
$signature = strtolower($parts['signature'] ?? '');
$algorithm = strtoupper($parts['algorithm'] ?? 'MD5');
$expected = match ($algorithm) {
    'MD5'               => md5($body . $secondKey),
    'SHA-256','SHA256'  => hash('sha256', $body . $secondKey),
    'SHA-1','SHA1'      => sha1($body . $secondKey),
    default              => null,
};
return $expected !== null && hash_equals($expected, $signature); // odporne na timing

// mapowanie statusu PayU → wewnętrzny (zachowawczo)
$status = match ($payuStatus) {
    'COMPLETED' => WebhookResult::STATUS_PAID,
    'CANCELED'  => WebhookResult::STATUS_FAILED,
    default     => WebhookResult::STATUS_IGNORED, // PENDING, WAITING_FOR_CONFIRMATION...
};

Dlaczego to ważne: hash_equals zamiast == chroni przed atakiem czasowym na porównanie podpisu. Brak second_key lub pustego nagłówka = natychmiastowe odrzucenie (return false).

Pola żądań API i mapowanie na bazę

Pole (żądanie)Za co odpowiadaKolumna (tabela.kolumna)Walidacja
product_external_idID produktu w sklepietransactions.product_external_idrequired · string · max:255
product_nameNazwa pozycjitransactions.product_namerequired · string · max:255
amountKwota w groszachtransactions.amountrequired · integer · min:1
currencyWalutatransactions.currencynullable · in:PLN
return_urlPowrót do skleputransactions.return_urlrequired · url · max:500
notify_urlWebhook skleputransactions.notify_urlnullable · url · max:500
tag_uidTag NFC źródłatransactions.tag_id (lookup)nullable · string · max:255
type (events)Typ zdarzeniaevents.typerequired · in:tag_open
code (BLIK)Kod BLIK Level 0— (do PayU)required · regex:/^\d{6}$/
method (PBL)Wybrany bank pay-by-link— (do PayU)required · in:<lista banków z POS>

Pełna mapa tras płatności (z PaymentController)

MetodaURLCo robi
GET/pay/{uuid}Wejście klienta; classic → 302 do PayU, app2app → hostowana strona BLIK/banki
POST/pay/{uuid}/blikBLIK Level 0 — kod 6 cyfr, potwierdzenie pushem w banku
POST/pay/{uuid}/bankpay-by-link — redirect otwierający aplikację banku na telefonie
POST/pay/{uuid}/onlineKlasyczna płatność (karta/przelew) — hostowana strona PayU
GET/pay/{uuid}/statusPolling statusu z rekonsyliacją u operatora
GET/pay/{uuid}/returncontinueUrl — odsyła na return_url sklepu
POST/webhooks/payuNotyfikacja PayU (OpenPayu-Signature) → markPaid/markFailed
Zrzut: wrzuć do public/img/docs/docs-gw-api.png (np. log webhooka PayU, ekran BLIK, odpowiedź API) — pojawi się tu automatycznie.
14.

Gateway: panel zarządzania

Aktywny

Panel bramki (osobne logowanie) służy do zarządzania sklepami, tagami NFC, statystykami i leadami. Zawiera też moduły demonstracyjne (anti-theft, tryb testowy płatności).

Sekcje panelu bramki
  • ShopControllerCRUD sklepów: nazwa, slug, klucz API, tryb płatności (classic/app2app), URL bazowy.
  • TagControllerZarządzanie tagami NFC: przypisanie do sklepu, etykieta, aktywność.
  • StatsControllerStatystyki płatności per sklep — transakcje, przychód.
  • LeadControllerLeady z landingu bramki + eksport do CSV.
  • DashboardControllerPulpit startowy panelu bramki.
  • LandingControllerStrona główna bramki (GET /) + zapis leada (POST /lead).
Moduły demonstracyjne

Demo Anti-theft (AntiTheftController, model AntitheftCheck) — szkielet kontroli integralności tagów NFC; obecnie zwraca status OK, gotowy do rozbudowy.

Demo Tryb testowy płatności (MockPaymentController, MockProvider) — pozwala przejść cały przepływ bez realnej bramki PayU (środowiska testowe).

Pola sklepu i taga
ModelPola
Shopname, slug, base_url, api_key, payment_mode (classic/app2app)
Tagshop_id, tag_uid, target_url, label, active
Leadname, email, phone, company, message

Pola formularza sklepu i mapowanie na bazę (Shop → shops)

PoleZa co odpowiadaKolumna (tabela.kolumna)Uwagi
nameNazwa sklepushops.namestring
slugIdentyfikator URL sklepushops.slugunique
base_urlAdres bazowy sklepu (do webhooków/powrotów)shops.base_urlstring
api_keyKlucz API (autoryzacja X-Api-Key)shops.api_keychar(64) unique, $hidden, generowany
payment_modeTryb płatności sklepushops.payment_modeenum: classic·app2app, default classic
Krytyczny kod: generowanie klucza API sklepu (Shop::generateApiKey) oraz ukrycie go w serializacji.
// 64 znaki hex = 256 bitów entropii
public static function generateApiKey(): string
{
    return bin2hex(random_bytes(32));
}
// klucz nigdy nie wycieka do JSON/odpowiedzi:
protected $hidden = ['api_key'];
// modele bramki zawsze czytają z bazy nfc_pay, niezależnie od tenanta hosta:
protected $connection = 'gateway';

Dlaczego to ważne: random_bytes to kryptograficznie bezpieczny generator (nie rand), a $hidden gwarantuje, że klucz nie pojawi się przypadkiem w żadnej odpowiedzi API. Stałe połączenie gateway izoluje dane finansowe od bazy aktywnego sklepu.

Pola taga NFC i leada (Tag → tags, Lead → leads)

PoleZa co odpowiadaKolumna (tabela.kolumna)Uwagi
shop_idSklep właściciel tagatags.shop_idFK → shops, cascadeOnDelete
tag_uidUID fizycznego taga NFCtags.tag_uidunique
target_urlAdres docelowy po zbliżeniutags.target_urlstring
labelEtykieta opisowatags.labelnullable
activeCzy tag aktywnytags.activeboolean, default true
name / email / phoneDane kontaktowe leadaleads.name·email·phonestring
companyFirma (opcjonalnie)leads.companynullable
messageTreść zapytanialeads.messagetext

Moduł Anti-theft (AntitheftCheckantitheft_checks: shop_id, status ok/warning, foreign_tags_found, checked_at) jest oznaczony w kodzie komentarzem // FIKCYJNE — moduł demo, brak realnej detekcji i zawsze zwraca foreign_tags_found = 0 — szkielet gotowy do rozbudowy.

Zrzut: wrzuć do public/img/docs/docs-gw-panel.png (np. lista sklepów, formularz taga, statystyki) — pojawi się tu automatycznie.
15.

Panel: logowanie i dashboard

Aktywny

Panel sklepu (/panel) chroniony jest logowaniem (middleware auth). Po zalogowaniu dashboard pokazuje kondycję sprzedaży. Łącznie panel udostępnia ponad 50 tras.

Logowanie (LoginController)
  • show()GET /panel/login — formularz (przekierowanie do dashboardu, jeśli zalogowany).
  • login()POST /panel/login — walidacja e-mail + hasło, sesja.
  • logout()POST /panel/logout — wylogowanie, unieważnienie sesji, regeneracja CSRF.
Dashboard (DashboardController)

GET /panel (nazwa panel.dashboard). Pokazuje metryki łączne i z 30 dni oraz wykres dziennej sprzedaży, korzystając z ShopStatsService:

  • otwarcia tagów NFC (tag_open), wyświetlenia (page_view), kliknięcia „Kup” (buy_click)
  • opłacone zamówienia, przychód łączny (suma amount), konwersja % (zakupy / otwarcia)
  • seria dziennych zakupów (30 dni) do wykresu słupkowego
Tabele
userseventsorders

Pola formularza logowania i mapowanie na bazę

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
emailLogin administratorausers.emailrequired · email
passwordHasło (porównywane z hashem)users.password (hash)required

Na co uważać: błąd logowania zwraca celowo ogólny komunikat „Niepoprawny login lub hasło.” (nie zdradza, czy istnieje konto o danym e-mailu). Po sukcesie regenerujemy sesję (session()->regenerate()) — ochrona przed session fixation. Logowanie jest „remember me” na stałe (drugi argument Auth::attempt(..., true)).

Krytyczny kod: logowanie do panelu (LoginController::login) — walidacja, próba uwierzytelnienia i twardnienie sesji.
$credentials = $request->validate([
    'email'    => ['required', 'email'],
    'password' => ['required'],
], [], ['email' => 'e-mail', 'password' => 'hasło']);

if (! Auth::attempt($credentials, true)) { // true = remember me
    return back()->withErrors(['email' => 'Niepoprawny login lub hasło.'])->onlyInput('email');
}
$request->session()->regenerate(); // anty session-fixation
return redirect()->intended(route('panel.dashboard'));

Dlaczego to ważne: ogólny komunikat błędu utrudnia enumerację kont, a regeneracja ID sesji po zalogowaniu zamyka klasę ataków na przejęcie sesji. Wylogowanie dodatkowo unieważnia sesję i regeneruje token CSRF.

Panel: ekran logowania i dashboard z metrykami.
Zrzut: wrzuć do public/img/docs/docs-panel-auth.png (np. formularz logowania, dashboard, wykres) — pojawi się tu automatycznie.
16.

Panel: Parafie + CRM

Aktywny

Najbogatsza sekcja panelu (ProductController): pełny CRUD parafii wraz z galerią zdjęć, opisem WYSIWYG, statystykami oraz wbudowanym CRM (statusy leada, notatki, przypisany handlowiec). Publikacja parafii sterowana jest statusem — ustawienie „aktywna” włącza widoczność publiczną.

Metody
  • index()Lista parafii z filtrem statusu i wyszukiwarką (nazwa/miasto/województwo) + liczniki statusów.
  • create() / store()Dodawanie parafii.
  • edit() / update()Edycja (z notatkami CRM).
  • toggle()Włącz/wyłącz widoczność.
  • deleteImage()Usunięcie zdjęcia z galerii.
  • stats()Statystyki konwersji parafii (eventy, wykres).
  • status()Szybka zmiana statusu CRM (AJAX) — steruje publikacją.
  • storeNote() / destroyNote()Notatki CRM (AJAX, JSON).
  • uploadEditorImage()Upload obrazu z edytora WYSIWYG (zwraca URL).
Statusy CRM (lejek wdrożenia)

kontakttestwdrożenieaktywna (każdy z własnym kolorem plakietki; „aktywna” = publikacja).

Notatki CRM (ParishNote → parish_notes)
PoleOpis
product_idParafia, której dotyczy
typekontakt · telefon · mail · spotkanie · inne
bodyTreść notatki
authorAutor
Galeria (ProductImage → product_images)

Pola: product_id, path, sort. Wielokrotny upload, sortowanie, usuwanie pojedynczych zdjęć.

Tabele
productsparish_notesproduct_imagessalespeopleeventsorders
Pliki
Panel/ProductController.php · views/…/panel/products/{index,form,stats}.blade.php · ShopStatsService.php

Pola formularza parafii i mapowanie na bazę

Reguły poniżej pochodzą wprost z ProductController::validated(). Cena wpisywana jest w złotych, a do bazy trafia w groszach. Status steruje publikacją: tylko aktywna ustawia active = true (parafia widoczna publicznie); pozostałe traktowane są jak lead (ukryta).

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
nameNazwa parafiiproducts.namerequired · string · max:255
cityMiejscowośćproducts.citynullable · string · max:255
priceKwota sugerowana (zł → grosze)products.price (unsignedInteger, grosze)required · regex /^\d{1,5}([.,]\d{1,2})?$/
tag_uidUID taga NFC parafiiproducts.tag_uidrequired · string · max:255 · unique(ignore self)
pickup_instructionInstrukcja / informacja dodatkowaproducts.pickup_instructionnullable · string · max:2000
description_htmlOpis WYSIWYG (Quill)products.description_html (text)nullable · string
phoneTelefon kontaktowy (CRM)products.phonenullable · string · max:255
websiteStrona www parafii (CRM)products.websitenullable · string · max:255
voivodeshipWojewództwo (CRM, mapa)products.voivodeshipnullable · string · max:255
statusStatus w lejku wdrożeniaproducts.status (string 20, default kontakt)required · in:kontakt,test,wdrozenie,aktywna
salesperson_idPrzypisany handlowiecproducts.salesperson_idnullable · integer · exists:salespeople,id
main_imageZdjęcie główneproducts.main_imageimage · max:8192 (KB)
gallery[]Zdjęcia galerii (wielokrotny upload)product_images.path, .sorteach: image · max:8192
slugIdentyfikator URL (auto z nazwy)products.sluggenerowany, unikalny iteracyjnie
Krytyczny kod: normalizacja ceny i sterowanie publikacją statusem (ProductController::validated) — pojedyncze miejsce, w którym złotówki stają się groszami, a status decyduje o widoczności.
// cena wpisywana w złotówkach — w bazie trzymamy grosze
$data['price'] = (int) round(((float) str_replace(',', '.', (string) $data['price'])) * 100);
// status steruje publikacją: 'aktywna' => publiczna, pozostałe => lead (ukryta)
$data['active'] = $data['status'] === 'aktywna';
$data['salesperson_id'] = $data['salesperson_id'] ?: null;

Dlaczego to ważne: konwersja zł→grosze jest tu jedyna i spójna; obsługuje zarówno kropkę, jak i przecinek (PL). Sprzężenie active = (status === 'aktywna') oznacza, że nie da się opublikować parafii inaczej niż przez ustawienie statusu „aktywna” — to zapobiega publikacji niedokończonych leadów.

Pola notatki CRM i mapowanie (ParishNote → parish_notes)

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
bodyTreść notatkiparish_notes.body (text)required · string · max:5000
typeTyp zdarzenia CRMparish_notes.type (string 20)required · in:kontakt,telefon,mail,spotkanie,inne
authorAutor (z konta użytkownika)parish_notes.authorauto: name/email zalogowanego
product_idParafia, której dotyczyparish_notes.product_idFK → products, cascadeOnDelete

Notatki dodawane są AJAX-em i zwracane jako JSON (bez przeładowania) wraz z etykietą typu i datą. Usunięcie notatki sprawdza, że należy ona do tej parafii (abort_unless($note->product_id === $product->id, 404)) — ochrona przed manipulacją ID.

Panel parafii: formularz CRM z galerią, statusem i notatkami.
Zrzut: wrzuć do public/img/docs/docs-panel-parafie.png (np. lista parafii z zakładkami statusów, formularz, panel notatek) — pojawi się tu automatycznie.
17.

Panel: Kategorie

Aktywny

Drzewo kategorii „Kogo wspieramy?” (CategoryController) sterujące sekcjami na stronie głównej. Obsługuje zagnieżdżanie (parent/child), zmianę kolejności i ikony, z ochroną przed cyklami.

Metody
  • index()Spłaszczone drzewo z wcięciami (rekurencja).
  • store() / update() / destroy()CRUD; usunięcie przenosi dzieci na poziom wyższy.
  • reorder()Zamiana kolejności (swap pozycji) w górę/dół.
Pola (Category → categories)
PoleOpis
parent_idKategoria nadrzędna (zagnieżdżenie)
label / label_html / label_textEtykiety (tekst + wersja HTML)
slugIdentyfikator URL (auto z nazwy)
introOpis sekcji
iconIkona (upload)
sourcenone (pusta) lub parishes (lista parafii)
positionKolejność w drzewie
activeWidoczność
Tabele
categories

Pola formularza kategorii i mapowanie na bazę

Reguły z CategoryController::validated(). label_text domyślnie przyjmuje wartość label, a label_html — escaped label_text; slug generowany jest z nazwy, gdy puste, i uspójniany do unikalności iteracyjnie.

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
parent_idKategoria nadrzędna (zagnieżdżenie)categories.parent_idnullable · integer · exists (≠ self)
labelNazwa kategoriicategories.labelrequired · string · max:255
label_htmlEtykieta z dopuszczalnym <br> (render)categories.label_html (text)nullable · string · max:1000
label_textCzysta wersja tekstowacategories.label_textnullable · string · max:255
slugIdentyfikator URLcategories.slugnullable · max:255 · unique(ignore self)
introOpis sekcjicategories.intro (text)nullable · string · max:2000
sourceŹródło pozycji na stronie kategoriicategories.source (string 20)required · in:none,parishes
positionKolejność w drzewiecategories.positionnullable · integer · 0–65535
activeWidocznośćcategories.activenullable · boolean
iconIkonka (upload do storage public)categories.iconnullable · image · max:8192
Krytyczny kod: ochrona przed cyklami w drzewie kategorii (CategoryController::parentOptions + descendantIds) — bez tego można by ustawić kategorię jako własnego potomka i zapętlić render.
// z listy możliwych rodziców wykluczamy edytowaną kategorię i WSZYSTKICH jej potomków
$excluded = $current ? $this->descendantIds($all, $current->id) : [];
foreach ($all->where('parent_id', $parentId) as $cat) {
    if (in_array($cat->id, $excluded, true)) { continue; } // blokada cyklu
    $options[$cat->id] = str_repeat('— ', $depth) . $cat->label_text;
}
// usunięcie rodzica NIE kasuje dzieci — FK nullOnDelete czyni je top-level

Dlaczego to ważne: rekurencyjne descendantIds zbiera całe poddrzewo, więc select rodzica nigdy nie zaproponuje opcji tworzącej cykl. Usunięcie kategorii dzięki nullOnDelete przenosi dzieci na poziom wyższy zamiast je kasować.

Panel kategorii: drzewo z wcięciami, zmiana kolejności i ikony.
Zrzut: wrzuć do public/img/docs/docs-panel-kat.png (np. drzewo kategorii, formularz, select rodzica) — pojawi się tu automatycznie.
18.

Panel: Handlowcy

Aktywny

CRUD handlowców (SalespersonController) z przypisaniem obsługiwanych województw i licznikiem przypisanych parafii. Integruje się z CRM parafii i mapą pokrycia.

Pola (Salesperson → salespeople)
PoleOpis
nameImię i nazwisko
email, phoneKontakt (opcjonalne)
voivodeshipsTablica obsługiwanych województw (JSON, 16 do wyboru)
activeCzy aktywny
Tabele
salespeopleproductspotential_parishes

Pola formularza handlowca i mapowanie na bazę

Reguły z SalespersonController::validated(). Lista województw jest słownikiem zamkniętym (stała Salesperson::VOIVODESHIPS, 16 pozycji RP) — każde wybrane województwo musi do niej należeć.

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
nameImię i nazwiskosalespeople.namerequired · string · max:255
emailE-mail kontaktowysalespeople.emailnullable · email · max:255
phoneTelefonsalespeople.phonenullable · string · max:255
voivodeships[]Obsługiwane województwasalespeople.voivodeships (text, cast array→JSON)nullable · array; each in:<16 województw>
activeCzy handlowiec aktywnysalespeople.activenullable · boolean
Krytyczny kod: walidacja województw względem słownika i zapis jako JSON (SalespersonController::validated + cast modelu).
// walidacja: każde województwo musi należeć do zamkniętego słownika
'voivodeships'   => ['nullable', 'array'],
'voivodeships.*' => ['string', 'in:' . implode(',', Salesperson::VOIVODESHIPS)],
// pusta lista => null (kolumna nullable); tablica => cast 'array' w modelu => JSON
$data['voivodeships'] = ! empty($data['voivodeships']) ? array_values($data['voivodeships']) : null;

Dlaczego to ważne: zamknięty słownik (in:) blokuje literówki i „lewe” województwa, dzięki czemu filtr mapy pokrycia i przypisania CRM zawsze działają na spójnych wartościach. Cast 'array' w modelu serializuje listę do JSON i deserializuje z powrotem bez ręcznego json_encode.

Panel handlowców: lista z licznikiem parafii i wybór województw.
Zrzut: wrzuć do public/img/docs/docs-panel-hand.png (np. lista handlowców, formularz z checkboxami województw) — pojawi się tu automatycznie.
19.

Panel: Parafie do obdzwonienia + mapa pokrycia

Aktywny

Lista leadów parafialnych (PotentialParishController) zaimportowanych z OpenStreetMap, z lejkiem obdzwaniania i interaktywną mapą pokrycia (Leaflet). Filtry: województwo, status, handlowiec, obecność telefonu. Paginacja po 50.

Metody
  • index()Lista z filtrami i paginacją; liczniki per status.
  • updateStatus()Zmiana statusu/handlowca/notatki/telefonu (AJAX); ustawia called_at przy pierwszym kontakcie.
  • coverageData()Zwraca punkty (JSON) do mapy — tylko rekordy ze współrzędnymi, z kolorem wg statusu.
Mapa pokrycia

GET /panel/coverage — mapa Leaflet z klastrowaniem markerów, licznikami per województwo i status, popupami (nazwa, miasto, telefon, status, handlowiec) i filtrami przeładowującymi dane AJAX-em.

Lejek obdzwaniania (status)

nowado_obdzwonieniazadzwonionozainteresowanadodana / odrzucona.

Pola (PotentialParish → potential_parishes)
PoleOpis
name, city, address, voivodeshipDane adresowe
denominationWyznanie
phoneTelefon
lat, lonWspółrzędne (mapa)
statusStatus leada (lejek)
salesperson_idProwadzący handlowiec
noteNotatka
called_atData pierwszego kontaktu
Tabele
potential_parishessalespeople

Pola aktualizacji leada i mapowanie na bazę

Reguły z PotentialParishController::updateStatus(). Dataset (~20 tys. parafii) pochodzi z OpenStreetMap (import parishes:import); telefon i adres uzupełniane są później (parishes:enrich / inline w panelu), bo OSM ich nie zawiera.

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
statusEtap w lejku obdzwanianiapotential_parishes.status (default nowa)required · in:nowa,do_obdzwonienia,zadzwoniono,zainteresowana,odrzucona,dodana
salesperson_idHandlowiec prowadzący leadpotential_parishes.salesperson_idnullable · integer · exists:salespeople,id
noteNotatka z rozmowypotential_parishes.note (text)nullable · string · max:5000
phoneTelefon (uzupełniany inline)potential_parishes.phonenullable · string · max:50
name·city·address·voivodeshipDane adresowe (z importu)potential_parishes.{name,city,address,voivodeship}import OSM
denominationWyznaniepotential_parishes.denominationimport OSM
lat·lonWspółrzędne do mapypotential_parishes.{lat,lon} (decimal 10,7)import OSM
called_atData pierwszego kontaktupotential_parishes.called_at (timestamp)auto przy „zadzwoniono”
Krytyczny kod: stempel pierwszego kontaktu i filtr obecności telefonu (PotentialParishController) — called_at ustawiamy raz, a domyślny widok pokazuje tylko parafie z numerem.
// stempel pierwszego kontaktu — ustawiany RAZ, przy przejściu do „zadzwoniono”
if ($data['status'] === 'zadzwoniono' && $potentialParish->called_at === null) {
    $potentialParish->called_at = now();
}
// filtr telefonu: domyślnie 'with' (pokazujemy TYLKO parafie z numerem)
$hasPhone = $request->query('has_phone', 'with');
if ($hasPhone === 'with') {
    $query->whereNotNull('phone')->where('phone', '!=', '');
}

Dlaczego to ważne: warunek called_at === null gwarantuje, że data pierwszego telefonu nie jest nadpisywana przy kolejnych zmianach statusu. Domyślny filtr with chroni handlowca przed listą 20 tys. rekordów bez numerów — od razu widzi to, co można obdzwonić.

Mapa pokrycia (coverageData) — transfer danych

Endpoint GET /panel/coverage ładuje markery AJAX-em (NIE wkleja 20 tys. punktów do HTML). coverageData() zwraca tylko niezbędne kolumny (id, name, city, voivodeship, lat, lon, phone, status, salesperson_id, note) plus kolor wg statusu — oszczędność transferu i czasu renderu. Filtry mapy są te same co listy, więc markery można zawężać re-fetchem.

Panel leadów: lista do obdzwonienia + interaktywna mapa pokrycia (Leaflet).
Zrzut: wrzuć do public/img/docs/docs-panel-leady.png (np. mapa z klastrami markerów, lista z filtrami, popup parafii) — pojawi się tu automatycznie.
20.

Panel: Sklep — produkty NFC

Aktywny

CRUD produktów sklepu donacyjnego (ShopItemController): minimalna kwota, tag NFC, produkt domyślny (tylko jeden), upload grafiki, kolejność i aktywność. Cena wpisywana w złotych, zapisywana w groszach.

Metody
  • index()Lista produktów (sort, miniatura, min. kwota, tag, domyślny, status).
  • store() / update()Zapis; ustawienie „domyślny” zdejmuje flagę z pozostałych produktów.
  • toggle() / destroy()Aktywacja / usunięcie.
Pola formularza

nazwa, slug (auto), min_amount_pln (→ grosze), tag_uid, kolejność, grafika (do 5 MB), is_default, active.

Tabele
shop_items

Pola formularza produktu NFC i mapowanie na bazę

Reguły z ShopItemController::validated(). Kwota minimalna wpisywana jest w złotych (min_amount_pln) i przeliczana na grosze. Slug generowany ze slugu lub z nazwy; grafika trafia na dysk public, a ścieżka zapisywana z prefiksem storage/.

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
nameNazwa produktushop_items.namerequired · string · max:255
slugIdentyfikator URLshop_items.slugnullable · max:255 · unique(ignore self)
min_amount_plnMinimalna kwota (zł → grosze)shop_items.min_amount (unsignedInteger, grosze)required · integer · 1–5000
tag_uidUID taga NFC kierującego na sklepshop_items.tag_uidnullable · max:255 · unique(ignore self)
sortKolejność na liścieshop_items.sortnullable · integer · 0–65535
image_fileGrafika produktushop_items.image (ścieżka „storage/…”)nullable · image · max:5120 (KB)
is_defaultProdukt domyślny („Serduszko”, auto-modal)shop_items.is_defaultboolean (tylko jeden naraz)
activeWidoczność produktushop_items.activenullable · boolean
Krytyczny kod: wymuszenie pojedynczego produktu domyślnego (ShopItemController::applyDefault) i przeliczenie zł→grosze.
// zł → grosze (kwota minimalna)
'min_amount' => (int) $data['min_amount_pln'] * 100,

// tylko JEDEN produkt może być domyślny — zdejmij flagę z pozostałych
if ($request->boolean('is_default')) {
    ShopItem::where('id', '!=', $item->id)->update(['is_default' => false]);
    $item->update(['is_default' => true]);
}

Dlaczego to ważne: produkt domyślny („Serduszko”) pokazuje się w modalu po wejściu na stronę sklepu — gdyby dwa były domyślne, modal byłby niejednoznaczny. applyDefault atomowo zdejmuje flagę ze wszystkich innych przed ustawieniem nowego domyślnego.

Panel sklepu: lista produktów NFC z miniaturą, kwotą i flagą domyślnego.
Zrzut: wrzuć do public/img/docs/docs-panel-sklep.png (np. lista produktów, formularz z kwotą i tagiem) — pojawi się tu automatycznie.
21.

Panel: Praca — stanowiska

Aktywny

CRUD ofert pracy (PositionController) z opisem WYSIWYG, lokalizacją, typem zatrudnienia, kolejnością i licznikiem aplikacji per oferta. Oferty pojawiają się publicznie na /praca.

Metody
  • index()Lista stanowisk z liczbą aplikacji.
  • store() / update() / toggle() / destroy()Pełny CRUD + włącz/wyłącz.
Tabele
job_positionsjob_applications

Pola formularza stanowiska i mapowanie na bazę

Reguły z PositionController::validated(). Oferty z active = true pojawiają się publicznie na /praca; sort ustala kolejność wyświetlania.

PoleZa co odpowiadaKolumna (tabela.kolumna)Walidacja
titleTytuł stanowiskajob_positions.titlerequired · string · max:255
locationLokalizacjajob_positions.locationnullable · string · max:255
employment_typeRodzaj zatrudnienia (np. etat, wolontariat)job_positions.employment_typenullable · string · max:255
description_htmlOpis WYSIWYGjob_positions.description_html (text)nullable · string
sortKolejność na liściejob_positions.sortnullable · integer · 0–65535
activeCzy oferta widoczna publiczniejob_positions.activenullable · boolean

Na co uważać: usunięcie stanowiska nie kasuje powiązanych zgłoszeń — FK job_applications.job_position_id ma nullOnDelete, więc aplikacje stają się „spontaniczne” (bez oferty), zamiast zniknąć. Lista stanowisk pokazuje licznik aplikacji per oferta (withCount('applications')).

Panel pracy: lista stanowisk z licznikiem aplikacji.
Zrzut: wrzuć do public/img/docs/docs-panel-praca.png (np. lista ofert, formularz stanowiska) — pojawi się tu automatycznie.
22.

Panel: Aplikacje rekrutacyjne

Aktywny

Skrzynka zgłoszeń rekrutacyjnych (ApplicationController) z pobieraniem CV z prywatnego dysku, statusami i filtrami. Licznik nieprzeczytanych widoczny w nawigacji panelu.

Metody
  • index()Skrzynka (najnowsze na górze), filtry po ofercie i statusie, liczniki.
  • show()Szczegóły zgłoszenia; oznacza jako przeczytane.
  • cv()Pobranie pliku CV z prywatnego dysku.
  • updateStatus()Zmiana statusu: do sprawdzenia / zaakceptowany / odrzucony.
  • destroy()Usunięcie zgłoszenia wraz z plikiem CV.
Tabele
job_applicationsjob_positions

Pola zgłoszenia rekrutacyjnego i mapowanie na bazę

Zgłoszenia trafiają z publicznego formularza /praca; panel służy do ich przeglądania, zmiany statusu i pobrania CV. job_position_id = NULL oznacza aplikację spontaniczną (bez konkretnej oferty).

PoleZa co odpowiadaKolumna (tabela.kolumna)Uwagi
job_position_idOferta, na którą wpłynęła aplikacjajob_applications.job_position_idnullable, FK nullOnDelete
name·email·phoneDane kandydatajob_applications.{name,email,phone}phone nullable
messageList motywacyjnyjob_applications.message (text)nullable
cv_pathŚcieżka pliku CV (dysk prywatny)job_applications.cv_pathdisk local (niepubliczny)
cv_original_nameOryginalna nazwa pliku CVjob_applications.cv_original_namenazwa przy pobieraniu
is_readCzy przeczytanejob_applications.is_readauto przy otwarciu
statusStatus rekrutacyjnyjob_applications.status (string 20, default pending)required · in:pending,accepted,rejected
Krytyczny kod: bezpieczne pobieranie CV (ApplicationController::cv) — plik leży na dysku prywatnym i jest serwowany wyłącznie zalogowanemu adminowi.
// CV na dysku 'local' (NIE public) — niedostępne z URL-a; tylko przez panel
abort_unless($application->cv_path && Storage::disk('local')->exists($application->cv_path), 404);
return Storage::disk('local')->download(
    $application->cv_path,
    $application->cv_original_name ?: basename($application->cv_path)
);
// usunięcie zgłoszenia kasuje też plik CV (sprzątanie dysku)

Dlaczego to ważne: CV zawiera dane osobowe — przechowywanie na dysku local (poza public/) oznacza, że nie da się go pobrać bez przejścia przez kontroler chroniony logowaniem. Walidacja exists chroni przed 500 przy zgubionym pliku.

Panel aplikacji: skrzynka zgłoszeń ze statusami i pobieraniem CV.
Zrzut: wrzuć do public/img/docs/docs-panel-apl.png (np. skrzynka zgłoszeń, szczegóły kandydata, przycisk CV) — pojawi się tu automatycznie.
23.

Panel: Wiadomości

Aktywny

Skrzynka wiadomości z formularza kontaktowego (MessageController) z licznikiem nieprzeczytanych.

Metody
  • index()Lista wiadomości (najnowsze na górze).
  • show()Szczegóły; oznacza jako przeczytane.
  • destroy()Usunięcie.
Tabele
contact_messages

Pola wiadomości kontaktowej i mapowanie na bazę

Wiadomości pochodzą z publicznego formularza kontaktowego; panel jest skrzynką odbiorczą (najnowsze na górze, licznik nieprzeczytanych w nawigacji). Otwarcie wiadomości oznacza ją jako przeczytaną.

PoleZa co odpowiadaKolumna (tabela.kolumna)Uwagi
nameImię i nazwisko nadawcycontact_messages.namestring
emailAdres zwrotnycontact_messages.emailstring
phoneTelefon (opcjonalnie)contact_messages.phonenullable
subjectTematcontact_messages.subjectnullable
messageTreść wiadomościcontact_messages.message (text)wymagane
is_readCzy przeczytanecontact_messages.is_readboolean, auto przy otwarciu

Na co uważać: model ma public $timestamps = false i własne created_at z DB default (useCurrent()) — nie ma kolumny updated_at. Oznaczenie jako przeczytane to jedyna mutacja rekordu w panelu (poza usunięciem).

Panel wiadomości: skrzynka kontaktowa z licznikiem nieprzeczytanych.
Zrzut: wrzuć do public/img/docs/docs-panel-wiad.png (np. lista wiadomości, widok pojedynczej wiadomości) — pojawi się tu automatycznie.
24.

Eventy i analityka

Aktywny

System rejestruje każde istotne zdarzenie — od zbliżenia telefonu po finalną wpłatę — co zasila statystyki w panelu i pozwala mierzyć konwersję. Zdarzenia trzymane są w tabeli events (z indeksem po typie i dacie), a agregacją zajmuje się ShopStatsService.

Typy zdarzeń (sklep)
TypKiedy
tag_openZbliżenie telefonu do tagu NFC
page_viewWyświetlenie strony parafii / produktu
buy_clickKliknięcie „Wesprzyj / Kup”
purchasePotwierdzona wpłata
Metody ShopStatsService
  • summary($productId, $days)Agregaty: otwarcia, wyświetlenia, kliknięcia, wpłaty, przychód, konwersja %.
  • dailyPurchases($productId, $days)Seria dziennych zakupów do wykresu.
  • formatPln($grosze)Formatowanie kwoty (grosze → zł).
Tabele
eventsorders

Dwie tabele events — sklep vs bramka

W systemie istnieją dwie odrębne tabele events (po jednej w bazie sklepu i w bazie bramki), o różnych zestawach typów. To celowe: sklep mierzy lejek sprzedażowy parafii/produktu, a bramka — lejek płatności. Obie mają indeksy po typie i dacie, by agregacje były szybkie.

TypModuł / tabelaKiedy powstajeKolumna źródłowa
tag_openSklep · events.typeZbliżenie telefonu do tagu NFCenum
page_viewSklep · events.typeWyświetlenie strony parafii/produktuenum
buy_clickSklep · events.typeKliknięcie „Wesprzyj / Kup”enum
purchaseSklep · events.typePotwierdzona wpłataenum
tag_openBramka · events.typeOtwarcie taga (raport z API sklepu)enum
payment_startedBramka · events.typeUtworzenie zamówienia u operatoraenum
payment_successBramka · events.typeWebhook/rekonsyliacja: opłaconeenum
payment_failedBramka · events.typeAnulowane / nieudaneenum
Krytyczny kod: rejestracja eventu z API (Api/EventController::store) — sklep raportuje zdarzenia kluczem API, a tag jest dowiązywany po UID w obrębie tego sklepu.
// POST /api/v1/events — sklep autoryzowany middleware (X-Api-Key → $shop)
$data = $request->validate([
    'type'    => ['required', 'in:tag_open'],
    'tag_uid' => ['nullable', 'string', 'max:255'],
]);
// tag szukany TYLKO w obrębie sklepu, który raportuje (izolacja tenantów)
$tag = Tag::where('shop_id', $shop->id)->where('tag_uid', $data['tag_uid'])->first();
Event::create(['shop_id' => $shop->id, 'tag_id' => $tag?->id, 'type' => $data['type']]);

Dlaczego to ważne: dowiązanie taga warunkowane shop_id uniemożliwia jednemu sklepowi zaraportowanie eventu na tag innego sklepu (izolacja danych w multi-tenant). tag?->id pozwala zapisać event nawet bez znanego taga.

Metryki dashboardu (ShopStatsService)

MetrykaJak liczonaŹródło
Otwarcia / wyświetlenia / kliknięciacount po typieevents
Opłacone zamówieniacount statusu paidorders.status
Przychód łącznysuma amount (grosze → zł)orders.amount
Konwersja %zakupy / otwarciaevents + orders
Seria dzienna (30 dni)group by dataorders / events
Analityka: lejek zdarzeń i wykres dziennej konwersji.
Zrzut: wrzuć do public/img/docs/docs-analityka.png (np. dashboard z metrykami, wykres słupkowy zakupów) — pojawi się tu automatycznie.
25.

Pełny schemat bazy danych

Aktywny

18 migracji budujących ~25 tabel w trzech bazach (multi-tenant). Poniżej wszystkie tabele z kluczowymi kolumnami.

Baza nfc_pay (bramka)
TabelaKluczowe kolumny
shopsname, slug, base_url, api_key, payment_mode
tagsshop_id, tag_uid, target_url, label, active
transactionsid (uuid), shop_id, tag_id, product_external_id, amount, currency, status, mode, provider_order_id, paid_at
eventsshop_id, tag_id, transaction_id, type, created_at
leadsname, email, phone, company, message
antitheft_checksshop_id, status, foreign_tags_found, checked_at
Baza nfc_shop1 (Taca / church)
TabelaKluczowe kolumny
productsname, city, purpose, slug, description_html, price, tag_uid, main_image, active, phone, website, voivodeship, status, salesperson_id
product_imagesproduct_id, path, sort
ordersid (uuid), product_id (nullable), transaction_id, amount, status, paid_at
eventsproduct_id, type, created_at
shop_itemsslug, name, image, min_amount, is_default, tag_uid, active, sort
categoriesparent_id, slug, label, label_html, intro, icon, source, position, active
salespeoplename, email, phone, voivodeships (JSON), active
potential_parishesname, city, address, voivodeship, denomination, phone, lat, lon, status, salesperson_id, note, called_at
parish_notesproduct_id, type, body, author
job_positionstitle, location, employment_type, description_html, active, sort
job_applicationsjob_position_id, name, email, phone, message, cv_path, cv_original_name, is_read, status
contact_messagesname, email, phone, subject, message, is_read
Tabele systemowe (Laravel)
userscachecache_locksjobs

Pełne typy kolumn z migracji — bramka (nfc_pay)

Tabela.kolumnaTyp (migracja)Atrybuty
shops.api_keystring(64)unique · generowany bin2hex(32) · $hidden
shops.payment_modeenum(classic,app2app)default classic
tags.tag_uidstringunique
tags.shop_idFK→ shops · cascadeOnDelete
transactions.iduuidprimary
transactions.amountunsignedIntegergrosze
transactions.currencychar(3)default PLN
transactions.statusenumcreated·pending·paid·failed·abandoned
transactions.modeenumclassic·app2app
transactions.return_urlstring(500)
transactions.provider_redirect_urlstring(1000)nullable
events.typeenumtag_open·payment_started·payment_success·payment_failed
antitheft_checks.foreign_tags_foundunsignedIntegerdefault 0 (moduł demo)

Pełne typy kolumn z migracji — sklep (nfc_shop1)

Tabela.kolumnaTyp (migracja)Atrybuty
products.priceunsignedIntegergrosze
products.tag_uidstringunique
products.slugstringunique
products.statusstring(20)default kontakt · index
products.salesperson_idFK→ salespeople · nullOnDelete
orders.iduuidprimary
orders.product_idFKnullable (zamówienia sklepu nie wiążą się z products)
orders.statusenumpending·paid·failed
shop_items.min_amountunsignedIntegergrosze
shop_items.tag_uidstringnullable · unique
shop_items.is_defaultbooleandefault false (tylko jeden true)
categories.parent_idFK→ categories · nullOnDelete (samoodniesienie)
categories.sourcestring(20)none·parishes
salespeople.voivodeshipstextcast array → JSON
potential_parishes.lat / .londecimal(10,7)współrzędne
potential_parishes.statusstringdefault nowa · index
parish_notes.product_idFK→ products · cascadeOnDelete
parish_notes.typestring(20)default kontakt
job_applications.job_position_idFKnullable · nullOnDelete
job_applications.cv_pathstringdysk local (prywatny)
job_applications.statusstring(20)default pending · index
contact_messages.is_readbooleandefault false

Mapa kluczy obcych i zachowań kasowania

Relacja (FK)onDeleteEfekt
tags.shop_id → shopscascadeusunięcie sklepu kasuje jego tagi
transactions.tag_id → tagsnullOnDeletetransakcja zostaje, traci tag
product_images.product_id → productscascadeusunięcie parafii kasuje galerię
parish_notes.product_id → productscascadeusunięcie parafii kasuje notatki
products.salesperson_id → salespeoplenullOnDeleteparafia traci handlowca, nie znika
categories.parent_id → categoriesnullOnDeletedzieci stają się top-level
job_applications.job_position_id → job_positionsnullOnDeleteaplikacja staje się spontaniczna
Ważne — multi-tenant na poziomie połączeń: tabela events istnieje w dwóch bazach o różnych enumach type; modele bramki wymuszają połączenie nfc_pay niezależnie od aktywnego tenanta.
// każdy model bramki:
protected $connection = 'gateway'; // zawsze nfc_pay, nawet gdy host = sklep
// połączenie 'mysql' jest podmieniane per host w ResolveTenant:
config(['database.connections.mysql.database' => $tenant['database']]);
Zrzut: wrzuć do public/img/docs/docs-baza.png (np. diagram ERD, lista tabel w phpMyAdmin) — pojawi się tu automatycznie.
26.

Stack technologiczny i statystyki

Aktywny

Lekki, nowoczesny stos oparty o Laravel 12 — bez ciężkiego frontendu SPA, co przekłada się na szybkość i prostotę utrzymania.

Technologie
Laravel 12 (PHP 8.2+) MySQL / MariaDB (3 bazy) Architektura multi-tenant Blade + CSS (bez SPA) Vite 7 + Tailwind 4 PayU REST API v2.1 BLIK · pay-by-link · 3DS Leaflet (mapy) Quill (WYSIWYG) Kolejki + Joby (async) Webhooki HMAC-SHA256 Open Graph / SEO Deploy: rsync + SSL
Statystyki kodu
WarstwaLiczby
Moduły aplikacji2 (Gateway · Storefront)
Kontrolery32 (15 Gateway + 17 Storefront)
Modele danych19
Serwisy i dostawcy płatnościGatewayClient, ShopStatsService, TransactionService, StatsService + PayUProvider, MockProvider
Migracje / tabele18 / ~25
Widoki Blade69
Trasy (web · API · webhooki)109
Arkusze stylów CSS6
Bazy danych3 (multi-tenant)
Linie kodu (PHP + Blade + CSS)~13 500

Dokument wygenerowany automatycznie na podstawie analizy kodu źródłowego. Moduły oznaczone „Aktywny” działają na produkcji please-support-me.com; „Demo” są zbudowane i działają w trybie pokazowym/testowym.