Pomiń do treści
Netova
← Wszystkie realizacje

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
NOIR Rental – wypożyczalnia aut premium ze Stripe, maszyną stanów rezerwacji i 2FA

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

Strona główna – katalog pojazdów i pasek rezerwacji
Web Vitals – mobile
Web Vitals – desktop
Checkout – zaliczka przez Stripe Elements
Panel admina – wykresy zaliczek netto i statusów rezerwacji
Portal klienta – status rezerwacji i autoryzacja kaucji
Spis treści

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). Funkcja assertMinorAmount() 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 strefie Europe/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 typowany IllegalStateTransitionError. Funkcja getAllowedAdminActions() 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-session z szyfrowanym, httpOnly, sameSite=lax ciasteczkiem; zmiana hasła podbija session_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 Origin względem kanonicznego APP_URL dla 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 ciasteczku httpOnly przypisanym 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-Length zanim 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_events z INSERT OR IGNORE na stripe_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:

  • 011 zamienia dawne, jawne tokeny resetu hasła na hashe — każdy link wydany wcześniej przestaje działać, celowo,
  • 012 przerywa migrację, jeśli w danych są nierozwiązane konflikty rezerwacji — decyzję biznesową musi podjąć człowiek, migracja jej nie zgaduje,
  • 013 i 014 budują 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,
  • 015 odbudowuje booking_slots bezpoś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 — react i react-dom celowo 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.

Zobacz projekt na żywo ↗

Inne wdrożenia

Powiązane realizacje

E-commerce · 11 min

Perfumeria RHO – koncepcyjny sklep headless na Next.js i Shopify

Projekt portfolio pokazujący headless e-commerce oparty na Shopify: dedykowany frontend w Next.js, Storefront API, Customer Account API, koszyk i checkout Shopify, warianty produktów, wyszukiwarkę, wishlistę, opinie zweryfikowanych klientów, SEO oraz zabezpieczone webhooki.

Porozmawiajmy o Twoim projekcie

Opisz cel, a wrócimy z konkretnym kierunkiem działania.

Umów konsultację