Aplikacje · Case study
NOIR Rental – wypożyczalnia aut premium ze Stripe, maszyną stanów rezerwacji i 2FA
Projekt własny pokazujący moje podejście do transakcyjnych aplikacji webowych: płatności Stripe z idempotentnymi webhookami, maszynę stanów rezerwacji i kaucji, TOTP 2FA oraz migracje bazy danych z pełnym audytem zmian.
- Klient
- Projekt własny Netova
- Publikacja
- Czas czytania
- 9 min
- Zakres
- Custom

99/100
Performance
100/100
SEO Score
97/100
Accessibility
TOTP 2FA, rate limiting, podpisane webhooki
Bezpieczeństwo
E-mail/hasło + TOTP 2FA
Logowanie
0.6s
Czas ładowania
Galeria
Spis treści
- Co chciałem pokazać
- Jak zbudowałem projekt
- 1. Domena przed interfejsem
- 2. Główny przepływ rezerwacji i płatności
- 3. Bezpieczeństwo — warstwa produkcyjna
- 4. Baza danych — Turso z rygorystycznymi migracjami
- 5. Panel administracyjny i portal klienta
- 6. SEO i dane strukturalne
- 7. Stack technologiczny
- 8. Architektura modułów
- Wdrożenie i zadania cykliczne
- Testy i walidacja
- Co wyróżnia ten projekt
NOIR Rental to mój własny projekt aplikacji webowej do wynajmu samochodów premium. Podobnie jak NetovaTodo nie symuluje współpracy z klientem — ale w przeciwieństwie do niego nie jest laboratorium pojedynczego zagadnienia. To pełna platforma transakcyjna: katalog pojazdów, rezerwacje z realną płatnością przez Stripe, kaucje autoryzowane osobno od zaliczki, panel administracyjny i portal klienta.
Celem nie było zbudowanie kolejnego "CRUD-a do rezerwacji". Zależało mi na tym, żeby zmierzyć się z problemami, które pojawiają się dopiero w produkcyjnych aplikacjach obsługujących prawdziwe pieniądze: równoczesne rezerwacje tego samego auta, webhooki, które mogą przyjść dwa razy albo nie w kolejności, kaucje autoryzowane dopiero blisko odbioru, oraz migracje bazy danych, które nie mogą po prostu "nadpisać" historycznych rezerwacji.
Co chciałem pokazać
- projektowanie transakcyjnej aplikacji e-commerce w Next.js, gdzie Server Actions są jedynym API,
- integrację płatności Stripe: PaymentIntents, Stripe Elements, podpisane webhooki, pełną idempotencję,
- modelowanie domeny biznesowej osobno od UI — pieniądze, kalendarz, maszynę stanów,
- warstwę bezpieczeństwa klasy produkcyjnej: 2FA, rate limiting, ochronę przed CSRF i nadużyciami,
- dyscyplinę migracji bazy danych z checksumami i pełnym audytem zmian,
- panel administracyjny z filtrowaniem po stronie bazy i wykresami,
- portal klienta z samodzielną autoryzacją kaucji,
- kompletne środowisko testowe: testy jednostkowe, integracyjne i e2e w Playwright.
Status projektu: projekt własny / portfolio. Repozytorium wprost zaznacza, że baza bywa zasilana danymi demonstracyjnymi — opis skupia się na architekturze transakcyjnej i twardych przypadkach brzegowych płatności, a nie na wymyślonych wynikach biznesowych.
Jak zbudowałem projekt
1. Domena przed interfejsem
Zanim napisałem pierwszy komponent, zdefiniowałem domenę biznesową w osobnym folderze src/domain/, niezależnie od Next.js i bazy danych:
money.ts— każda kwota jest liczona i przechowywana wyłącznie w groszach (integer minor units). FunkcjaassertMinorAmount()odrzuca każdą wartość, która nie jest bezpieczną liczbą całkowitą — żadnych błędów zaokrągleń przy liczeniu zaliczki czy kaucji.rental-period.ts— kalendarz wynajmu liczony jest w strefieEurope/Warsaw, z dwuprzebiegowym algorytmem rozwiązywania przesunięcia UTC, który poprawnie obsługuje zmianę czasu (DST) bez przesuwania dat rezerwacji o godzinę.reservation-state.ts— jawna maszyna stanów dla rezerwacji, zaliczki i kaucji. Każde przejście jest sprawdzane względem mapy dozwolonych przejść; nielegalne przejście rzuca typowanyIllegalStateTransitionError. FunkcjagetAllowedAdminActions()wylicza dostępne akcje wyłącznie na podstawie aktualnego stanu — panel admina fizycznie nie może zaproponować akcji, której domena by odrzuciła.checkout-identity.ts— osobna funkcja decyzyjna określająca, czy checkout tworzy nowego gościa, wznawia przerwaną sesję gościa, czy odrzuca próbę użycia cudzego adresu e-mail.
Ta warstwa nie zależy od Next.js, requestów ani bazy danych — da się ją przetestować w izolacji, co robię w tests/unit/domain.test.ts.
2. Główny przepływ rezerwacji i płatności
1. Serwer waliduje pojazd, miasto, daty i wersje zgód (regulamin/RODO)
2. Transakcja zapisu liczy cenę w groszach i "claim'uje" jeden slot
w bazie na każdy dzień wynajmu
3. Serwer tworzy idempotentny Stripe PaymentIntent na zaliczkę —
przeglądarka dostaje tylko to, czego potrzebuje Stripe Elements
4. Podpisany webhook Stripe potwierdza zaliczkę i przenosi
rezerwację do stanu "confirmed"
5. Bliżej odbioru admin uruchamia osobną autoryzację kaucji
(off-session, manual capture) na zapisanej metodzie płatności
6. Webhooki aktualizują status kaucji; admin może rozpocząć
lub zakończyć wynajem wyłącznie przez dozwolone przejścia domeny
Rezerwacja nigdy nie zmienia statusu na podstawie ekranu sukcesu w przeglądarce — jedynym źródłem prawdy jest podpisany webhook. Kaucja żyje własnym cyklem życia, niezależnym od rezerwacji, a admin może zainicjować jej autoryzację dopiero w oknie DEPOSIT_AUTHORIZATION_LEAD_DAYS przed odbiorem — nie wcześniej.
Checkout gościa nie wymaga konta przed płatnością — tworzy pasywny, bezhasłowy rekord klienta. Po udanym webhooku zaliczki system wysyła jednorazowy link aktywacyjny; dopiero ustawienie hasła udostępnia tę samą rezerwację w portalu klienta.
Ochrona przed podwójną rezerwacją nie jest tylko logiką aplikacji. Tabela booking_slots ma ograniczenie UNIQUE(car_id, service_date) — nawet przy dwóch równoczesnych żądaniach na to samo auto i ten sam dzień, baza danych fizycznie odrzuci drugi zapis. Logika aplikacji jest tu drugą linią obrony, nie jedyną.
3. Bezpieczeństwo — warstwa produkcyjna
Podobnie jak w NetovaTodo, zacząłem od modelu zagrożeń — tu jednak stawka jest wyższa, bo w grę wchodzą realne płatności.
Zaadresowany model zagrożeń:
- Przejęcie sesji →
iron-sessionz szyfrowanym,httpOnly,sameSite=laxciasteczkiem; zmiana hasła podbijasession_version, co natychmiast unieważnia wszystkie inne aktywne sesje - Brute force logowania → rozproszony rate limiting (Upstash Redis, sliding window) liczony osobno po adresie IP i po zahashowanym adresie e-mail; w produkcji limity krytyczne dla bezpieczeństwa failują zamknięte (fail closed), jeśli Redis jest niedostępny
- Enumeracja kont → identyczne, generyczne komunikaty przy błędnym logowaniu i przy rejestracji na zajęty adres
- Obejście 2FA → TOTP (
otplib) z jednorazowym, krótkotrwałym ciasteczkiem pre-auth (httpOnly,secure,sameSite=strict, ograniczonym do ścieżki/auth/login); hasło nigdy nie trafia do URL ani local storage; kod QR generowany lokalnie — sekret nigdy nie jest wysyłany do zewnętrznego serwisu - CSRF / mutacje cross-site → weryfikacja nagłówka
Originwzględem kanonicznegoAPP_URLdla każdej publicznej mutacji - Wyciek stanu płatności → dostęp do ekranu checkoutu chroni krótkotrwały token podpisany HMAC-SHA256; serwer przechowuje wyłącznie jego hash (SHA-256), porównywany przez
timingSafeEqual, w ciasteczkuhttpOnlyprzypisanym do konkretnej rezerwacji — nigdy w URL ani Web Storage - DoS przez nadmiarowe payloady → strumieniowy odczyt body z twardym limitem bajtów, który waliduje też nagłówek
Content-Lengthzanim zacznie czytać — brakujący lub zaniżony nagłówek nie pozwala ominąć limitu - Fałszowanie / powtórka webhooków → weryfikacja podpisu Stripe plus tabela
stripe_webhook_eventszINSERT OR IGNOREnastripe_event_id— to samo zdarzenie jest przetwarzane dokładnie raz, nawet jeśli Stripe dostarczy je dwukrotnie - Wstrzyknięcie formuł w CSV →
csvCell()neutralizuje wiodące znaki= + - @przed zacytowaniem wartości - Wyciek danych w logach → strukturalny logger JSON z rekurencyjną redakcją kluczy pasujących do wzorca hasło/sekret/token/autoryzacja/cookie/TOTP, z limitem głębokości i przycinaniem długich wartości
- Eskalacja uprawnień w adminie → middleware sprawdza jedynie obecność ciasteczka sesji (to tylko optymistyczna, pierwsza bariera UI); każda mutacja admina ponownie weryfikuje rolę po stronie serwera, najbliżej danych
- Open redirect po logowaniu → adres powrotu jest kanonizowany do wewnętrznych ścieżek; warianty protocol-relative, z odwróconym ukośnikiem i znakami kontrolnymi są odrzucane
4. Baza danych — Turso z rygorystycznymi migracjami
Migracje leżą w src/db/migrations/ jako 17 ponumerowanych plików SQL. Runner:
- stosuje je w kolejności nazw, każdą w osobnej transakcji,
- zapisuje checksumę SHA-256 każdej zaaplikowanej migracji w tabeli
_migrations, - odmawia uruchomienia, jeśli treść już zaaplikowanej migracji została zmieniona.
Pięć migracji wymaga świadomej uwagi przy wdrożeniu — to nie są zwykłe ALTER TABLE:
011zamienia dawne, jawne tokeny resetu hasła na hashe — każdy link wydany wcześniej przestaje działać, celowo,012przerywa migrację, jeśli w danych są nierozwiązane konflikty rezerwacji — decyzję biznesową musi podjąć człowiek, migracja jej nie zgaduje,013i014budują audytowalny proces rekoncyliacji legacy'owych kwot: dla każdej niejednoznacznej wartości operator musi jawnie zadeklarować, czy była zapisana w złotówkach czy w groszach, wraz z uzasadnieniem — nic nie jest przeliczane w milczeniu, a każda korekta trafia do osobnej tabeli audytowej,015odbudowujebooking_slotsbezpośrednio ze źródła prawdy (reservations), więc samego usunięcia wierszy diagnostycznych nie da się użyć jako obejścia.
Pieniądze są przechowywane i liczone wyłącznie w groszach — formatowanie na złotówki (Intl.NumberFormat) dzieje się dopiero na wyjściu, nigdy w logice biznesowej.
Osobny mechanizm audytu — tabela reservation_events z ograniczeniem idempotency_key UNIQUE i typem aktora (gość / klient / admin / system / Stripe) — zapisuje każde zdarzenie w cyklu życia rezerwacji, budując pełną historię bez polegania na logach aplikacji.
5. Panel administracyjny i portal klienta
Lista rezerwacji w panelu admina jest serwerowa od początku do końca — wyszukiwanie, filtrowanie po statusie rezerwacji, zaliczki, kaucji, pojeździe, mieście i zakresie dat oraz paginacja (20 wierszy/stronę) wykonują się w bazie danych. Przeglądarka nigdy nie pobiera całej historii rezerwacji, żeby ją przefiltrować lokalnie.
Dashboard (Recharts) pokazuje wykres obszarowy zaliczek netto z przełącznikiem zakresu (7/14/30/60/90 dni) oraz wykres kołowy rozkładu statusów rezerwacji — oba zasilane zapytaniami dostępnymi wyłącznie dla admina.
Zarządzanie flotą (dodawanie, edycja, usuwanie pojazdów i miast) korzysta z podpisanego uploadu do Cloudinary — serwer wydaje jednorazowy podpis (chroniony sprawdzeniem roli i rate limitem), sekret API nigdy nie trafia do przeglądarki. Eksport rezerwacji do CSV jest zabezpieczony przed wstrzyknięciem formuł arkuszowych.
Portal klienta pokazuje status własnych rezerwacji, pozwala samodzielnie przeprowadzić autoryzację kaucji (Stripe Elements + Server Actions) i zarządzać ustawieniami konta, w tym 2FA.
6. SEO i dane strukturalne
Strona pojazdu wstrzykuje JSON-LD zgodny ze schema.org (Car, OfferForLease, AutoRental, BreadcrumbList) — poprawnie zescapowany przeciw XSS (< zamieniane na \u003c) zamiast surowego dangerouslySetInnerHTML bez zabezpieczeń. Do tego dochodzą dynamiczny sitemap.ts i robots.ts.
Web Vitals są raportowane z przeglądarki (useReportWebVitals) do własnego endpointu, chronionego sprawdzeniem same-origin, walidacją Zod i limitem 60 żądań/min — metryki trafiają do strukturalnych logów, gotowe do podpięcia pod dowolny system obserwowalności.
7. Stack technologiczny
- Next.js 16 (App Router) — Server Actions jako jedyne API; zero osobnego backendu
- React 19 —
reactireact-domcelowo przypięte do identycznej wersji - TypeScript 5 — pełne typowanie end-to-end
- Tailwind CSS 3 + shadcn/ui — komponenty na bazie Radix UI, styl „new-york"
- Turso / libSQL (
@libsql/client) — serverless SQLite z transakcyjnymi migracjami - Stripe v22 — PaymentIntents, Stripe Elements, podpisane webhooki, pełna idempotencja
- iron-session — szyfrowane, bezstanowe sesje
- otplib + bcryptjs — TOTP 2FA i hashowanie haseł (12 rund bcrypt)
- Zod v4 — walidacja każdego wejścia po stronie serwera
- Upstash Redis — rozproszony rate limiting z fallbackiem in-memory na dev
- Cloudflare Turnstile — ochrona publicznych formularzy (fail-closed w produkcji)
- Resend — e-mail transakcyjny z własnym outboxem w bazie danych
- Cloudinary + Sharp — podpisane uploady i przetwarzanie zdjęć floty
- Recharts — wykresy w panelu administracyjnym
- react-hook-form + @hookform/resolvers — formularze
- Vercel — hosting, Edge Network, CI/CD z
git push
8. Architektura modułów
| Warstwa | Odpowiedzialność |
|---|---|
| domain/ | Czysta logika biznesowa: pieniądze w groszach, kalendarz wynajmu strefy Europe/Warsaw, maszyna stanów rezerwacji / zaliczki / kaucji |
| db/ | Klient Turso, migracje z checksumami, zapytania, atomowe przejścia stanu rezerwacji, outbox e-mail |
| actions/ | Server Actions — jedyne API aplikacji: auth, płatności, rezerwacje, flota, newsletter |
| lib/ | Bezpieczeństwo: sesje, rate limiting, tokeny checkoutu, Stripe, logger z redakcją, limity rozmiaru requestów |
| app/admin | Panel administracyjny: flota, rezerwacje, miasta, newsletter, ustawienia, wykresy Recharts |
| app/portal | Portal klienta: status rezerwacji, autoryzacja kaucji, ustawienia konta |
| tests/ | Testy jednostkowe i integracyjne (Node test runner) oraz e2e (Playwright) na przygotowanej bazie testowej |
Wdrożenie i zadania cykliczne
GitHub → Vercel CI/CD → Edge Network
↓
Next.js 16 (Vercel)
↓
Turso (edge SQLite)
Dwa endpointy — przetwarzanie kolejki e-maili i porządkowanie wygasłych rezerwacji — muszą być wywoływane cyklicznie, z nagłówkiem Authorization: Bearer <CRON_SECRET>. Konfiguracja crona w repozytorium jest świadomie wyłączona domyślnie — uruchomienie zadań w tle wymaga jawnej decyzji przy wdrożeniu, a nie przypadkowego kosztu po stronie hostingu. To działa też w drugą stronę: nawet gdyby cron nie zadziałał na czas, rekoncyliacja ze Stripe i idempotencja bazy danych chronią przed niespójnym stanem płatności.
Testy i walidacja
Jedno polecenie spina cały pipeline jakości:
npm run check
# = lint && typecheck && test && build
Testy jednostkowe i integracyjne uruchamia natywny Node test runner (bez Jest/Vitest), z flagą --conditions=react-server odzwierciedlającą sposób, w jaki Next.js rozwiązuje moduły React Server Components. Pakiet integracyjny konstruuje poprawnie podpisane żądania webhook Stripe i sprawdza obsługę zdarzeń dostarczonych dwukrotnie oraz w złej kolejności — generyczny stripe trigger bez metadanych rezerwacji tego nie weryfikuje.
Testy e2e (Playwright, Chromium, locale pl-PL, strefa Europe/Warsaw) pokrywają logowanie administratora, pełny cykl życia rezerwacji i publiczny proces bookingu, uruchamiane na dedykowanej, przygotowywanej przed każdym runem bazie testowej.
| Obszar | Implementacja |
|---|---|
| Płatności | Stripe PaymentIntents + Elements, podpisane webhooki, pełna idempotencja |
| Ochrona przed podwójną rezerwacją | Ograniczenie bazy danych UNIQUE(car_id, service_date) |
| Uwierzytelnianie | iron-session + TOTP 2FA (otplib) + bcrypt (12 rund) |
| Rate limiting | Upstash Redis (sliding window), fail-closed w produkcji |
| Migracje bazy | 17 migracji, checksumy SHA-256, transakcyjne |
| Waluta | Wyłącznie integer minor units (grosze) |
| Audyt zdarzeń | Tabela reservation_events z idempotency_key |
| Testy | Unit + integration (Node test runner) + e2e (Playwright) |
| SEO | JSON-LD (Car / OfferForLease / BreadcrumbList), sitemap.ts, robots.ts |
Co wyróżnia ten projekt
To nie jest kolejny katalog produktów z formularzem kontaktowym. NOIR Rental pokazuje, że potrafię zaprojektować i zbudować platformę transakcyjną, w której pieniądze, stan rezerwacji i spójność danych są traktowane równie poważnie, jak w prawdziwym produkcie SaaS: jawna maszyna stanów zamiast rozrzuconych po kodzie if-ów, migracje z audytem zamiast cichych nadpisań, webhooki idempotentne zamiast nadziei, że Stripe nie dostarczy zdarzenia dwa razy.
Najważniejsze elementy to: modelowanie domeny niezależne od frameworka, płatności Stripe zbudowane wokół idempotencji i webhooków jako jedynego źródła prawdy, ochrona przed podwójną rezerwacją na poziomie bazy danych, dwuskładnikowe uwierzytelnianie oraz dyscyplina migracji, która nie pozwala cichym decyzjom wpłynąć na dane produkcyjne.
Potrzebujesz platformy rezerwacyjnej lub e-commerce z podobnym poziomem bezpieczeństwa i integracją płatności? Porozmawiajmy o zakresie i architekturze projektu.