Case study · e-commerce & ticketing
Od PrestaShopu k vlastní ticketing platformě
Kompletní přestavba e-shopu na prodej vstupenek na sportovní eventy: z legacy PrestaShopu na vlastní Next.js platformu s živou platební bránou, automatickou synchronizací čtyř dodavatelských feedů a dvouměnovým prodejem CZK/EUR — se zachováním celé historie objednávek i zákazníků a s navazujícím číslováním objednávek.
- KlientSportActions
- Rok2026
- RealizaceRedHand
- sportactions.cz ↗
Ve zkratce
SportActions běžel na PrestaShopu, který přestával stačit rozsahem i flexibilitou — hlavně kvůli napojení na externí dodavatelské feedy vstupenek a dvouměnovému prodeji (CZK/EUR). Postavili jsme novou platformu na Next.js 15 s doménově oddělenou business logikou, napojili živou platební bránu ČSOB s automatickou fakturací přes iDoklad, synchronizaci čtyř dodavatelských feedů s ochranou ručně nastavených cen a překladovou pipeline nad OpenAI. Historie objednávek i zákazníků přešla kompletně — každý přenesený záznam je označený původem a nové číslování objednávek plynule navazuje na legacy řadu.
4 599
eventů v katalogu — 11 343 nabízených vstupenek, plněno ze 4 dodavatelských feedů
1 449
zákaznických účtů s historií až do ledna 2016, 214 objednávek přeneseno beze ztráty
4
dodavatelské feedy synchronizované automaticky (cron každé 2 hodiny), ruční ceny se nepřepisují
ČSOB + iDoklad
v ostré produkci — platba ověřená reálnou transakcí, faktura se vystavuje automaticky
Výchozí situace
Předchozí e-shop stál na PrestaShopu. Fungoval, ale s růstem katalogu narážel na limity: katalog se plnil ručně, i když většina vstupenek pochází z dodavatelských nabídek, a ceny z feedů se nedaly čistě oddělit od cen nastavených obchodně.
Dvouměnový prodej (CZK/EUR) byl kostrbatý — kurz i zaokrouhlování se řešily mimo systém. Vývoj nových funkcí byl pomalý; každá úprava znamenala boj se zděděnou architekturou.
Cílem nebyl redesign PrestaShopu, ale náhrada za platformu, kterou lze rozvíjet bez tření — při zachování všeho, co v legacy systému vzniklo za roky provozu.
Cíl a omezení
Omezení, která z projektu dělají inženýrsky zajímavý případ:
- Živé platby, nulová tolerance na chybu. Platební brána běží v ostrém režimu — každá objednávka je reálná transakce, takže idempotence a přesná validace ceny nejsou „nice to have".
- Migrace bez ztráty. Zákazníci, odběratelé newsletteru i historické objednávky musely přejít beze ztráty a zůstat zpětně dohledatelné.
- Kontinuita čísel objednávek. Nové objednávky musely navázat na poslední číslo z PrestaShopu — číslo objednávky slouží zároveň jako variabilní symbol platby, takže restart číselné řady nepřipadal v úvahu.
- Dvě měny jako první třída. CZK i EUR napříč celým katalogem, kurz řízený systémem.
- Autonomní ceny z feedů, ale bez přepisu ručních zásahů. Feed nesmí přemazat cenu, kterou obchod nastavil ručně.
- Vícejazyčnost s prostorem pro další jazyky bez přepisování komponent.
Řešení — architektura a klíčová rozhodnutí
Platforma je postavená na Next.js 15 (App Router) s ostře oddělenými částmi: veřejný storefront, admin a API routy. Veškerá business logika žije v jedné doménové vrstvě (`src/lib/domain/`) — objednávky, platby, fakturace, synchronizace feedů, cenový engine, překlady, e-maily. Routy zůstávají tenké; pravidla jsou na jednom místě a dají se testovat izolovaně (E2E přes Playwright, včetně testu idempotence platebního callbacku).
Ceny se počítají centrálně: z nákupní ceny dodavatele (ta může být v EUR, GBP i CZK) přes marži definovanou per kategorie vzniká prodejní cena v CZK a z ní odvozená prodejní cena v EUR. Kurzy řídí systém přes tabulku směnných kurzů spravovanou v adminu — obě měny jsou tak vždy konzistentní a nikde se nedvojí konverze.
Cron pravidelně stahuje dostupnost a ceny ze čtyř dodavatelských zdrojů. Klíčový detail: pokud obchod nastaví cenu ručně, záznam dostane příznak `manual_price_override` a feed při další synchronizaci aktualizuje všechno kromě ceny — a ručně spravovaný záznam nesmaže, ani když z feedu zmizí. Katalog se tak drží aktuální, aniž by mazal obchodní rozhodnutí.
Aplikace výsledku platby je idempotentní: funkce atomicky „zabere" přechod do stavu zaplaceno, takže duplicitní callback od brány nespustí post-payment akce podruhé a opožděný neúspěšný callback nepřepíše už zaplacenou objednávku. Callback se ověřuje kryptograficky, checkout navíc validuje cenu každé položky server-side proti databázi (per měna) — klient nemůže cenu podstrčit. Zaseknuté platby dočišťuje pravidelná reconciliace stavu proti bráně. Po zaplacení se automaticky vystavuje faktura v iDokladu (DPH podle země konání akce, VS = číslo objednávky).
i18n vrstva odděluje texty od komponent; překlady katalogu generuje pipeline nad OpenAI `gpt-4o-mini` s lokálním Ollama fallbackem — jednorázová migrace překladů zvládla 1 605 eventů a 1 398 vstupenek za 52 minut s nulovou chybovostí. Aktivně běží CS/EN, schéma i překladová pipeline počítají s dalšími jazyky (DE/ES připraveny v datovém modelu).
Součástí přestavby byl bezpečnostní audit a hardening: server-side validace cen a slevových kódů, zabezpečení admin přístupu, kryptografické ověření platebních callbacků, rate-limity a antispam na formulářích.
Rozhodnutí a trade-offy
| Rozhodnutí | Alternativa | Proč takto |
|---|---|---|
| Next.js 15 App Router | Zachovat PrestaShop / hotový e-commerce SaaS | SSR pro výkon a SEO, plná kontrola nad doménovou logikou — feedy, dvě měny a resale nabídky se do krabicového řešení nevešly |
| Drizzle ORM + PostgreSQL 16 | Prisma / zůstat na MySQL | Typově přísné schéma, předvídatelné SQL, relační data (objednávky ↔ vstupenky ↔ ceny ↔ kurzy) |
| Business logika v lib/domain | Logika v API routách | Testovatelnost, znovupoužití — cron, admin i checkout volají tytéž funkce |
| Vlastní pricing engine | Ceny počítané ad-hoc v checkoutu | Jedno místo pravdy: nákupní cena → marže dle kategorie → prodejní CZK → odvozené EUR; checkout validuje proti DB |
| OpenAI gpt-4o-mini + Ollama fallback | Jen jeden překladač | Kvalita a rychlost primárně, lokální fallback jako pojistka |
| Číslování navázané na legacy sekvenci | Nová číselná řada | Číslo objednávky = VS platby = identifikátor pro bránu; kontinuita pro účetnictví |
Migrace bez ztráty
Migrace z PrestaShopu do PostgreSQL byla navržená tak, aby nešlo nic ztratit a vše bylo dohledatelné. Přeneseno 1 449 zákaznických účtů (nejstarší z ledna 2016), 1 282 odběratelů newsletteru a 214 historických objednávek včetně původních stavů.
Každá přenesená objednávka nese příznak původu (`source: prestashop` v metadatech včetně původního stavu z legacy systému), takže je kdykoli zpětně dohledatelná a odlišitelná od nových. Číslování objednávek navazuje: stará řada skončila číslem 997, nová sekvence začíná 998 a formát zůstal stejný — číslo objednávky slouží zároveň jako variabilní symbol platby a identifikátor transakce pro bránu.
Staré URL nezemřely: middleware překládá PrestaShop adresy 301 redirectem na nové kategorie a detaily (mapa pokrývá 30 kategorií a 1 640 produktů v obou historických tvarech adres), takže SEO ani uložené odkazy zákazníků nepadají do 404.
Nejtěžší moment: když feed „uklidil" živou nabídku
Každá poctivá case study má jeden reálný problém. Tady to bylo tiché selhání synchronizace, které začalo mazat živé produkty.
Nabídka jednoho dodavatele na webu klesla ze 412 na 197 produktů — bez chybové hlášky. Příčiny byly tři, vrstvené na sobě: tichý fail s prázdným `catch {}`, kdy selhání stažení vstupenek nebylo vidět v logu a prázdný seznam pak ve větvi „smaž, co ve feedu není" smazal všechny vstupenky eventu, které dodavatel ve skutečnosti stále nabízel. K tomu rate limit dodavatelského API na klouzavém okně, kdy krátký retry nepomáhal a jeden fail spouštěl řetěz dalších. A nakonec chybějící označení zdroje u nových řádků, takže metriky ztrátu „neviděly" — vypadalo to na mnohem větší rozsah, než jaký reálně nastal.
1// GUARD: pri selhani fetche je feedIds prazdne a bez guardu
2// by se smazaly VSECHNY tickety eventu, ktere dodavatel stale nabizi.
3if (ticketsFetchOk) {
4 for (const prev of existing) {
5 if (!prev.feed_ticket_id) continue
6 if (feedIds.has(prev.feed_ticket_id)) continue
7 if (prev.manual_price_override) continue
8 await db.delete(saTickets).where(eq(saTickets.id, prev.id))
9 }
10}
Selhání stažení dat nově znamená „nesahej na nic": nemaže se, nepřepisují se agregáty eventu, incident se počítá a loguje. Rate limit řeší exponenciální backoff a každý vložený řádek nese označení zdroje. Poučení: u synchronizace, která smí mazat, je „prošlo to bez chyby" nebezpečnější než tvrdý pád — destruktivní větev musí být podmíněná prokazatelně úspěšným čtením, protože prázdný výsledek a chyba nejsou totéž.
Výsledky
Čísla z produkční databáze a gitu, snapshot 19. 7. 2026.
Eventy podle zdroje feedu
Eventy podle zdroje feedu (4 599 celkem, 66 kategorií) — katalog plní feedy, ne ruce. Názvy dodavatelů anonymizovány.
Objednávky v čase — migrované vs. nové
Objednávky podle čtvrtletí vzniku (u migrovaných zachováno původní datum): legacy = přenesené z PrestaShopu, nová platforma = objednávky vzniklé po spuštění. 2026 Q3 = jen červenec (částečné čtvrtletí). Rozjezd nové platformy od dubna 2026.
Růst zákaznické báze
Kumulativní počet zákaznických účtů podle roku registrace — historie od ledna 2016 přenesena celá.
1 605 + 1 398
eventů a vstupenek přeloženo
jednorázová migrace překladů, 52 minut, 0 chyb
−85 %
velikost mediální složky
hromadná konverze uploadů na WebP: 306 MB → 46 MB
2,6 MB → ~70 kB
datový payload hero obrázku na mobilu
optimalizace titulní stránky
301 commitů za necelé čtyři měsíce (duben–červenec 2026, ke dni 19. 7.) v samostatném repu při běžícím provozu — malé, vratné kroky se zálohou před každou změnou.
Retrospektiva
Co bychom příště udělali jinak:
- Peněžní sloupce jako `numeric` od začátku, ne dodatečná migrace z pohyblivé desetinné čárky. U peněz se přesnost nevyplácí odkládat — převod je dnes v backlogu jako riziková operace, která mohla být zadarmo.
- Destruktivní větve synchronizace psát defenzivně od prvního dne — guard „smaž jen po prokazatelně úspěšném čtení" vznikl až po incidentu, který mohl být levnější jako pravidlo než jako oprava.
- Časové zóny feedů vyřešit návrhem, ne opravou. Dodavatelé posílají časy akcí v místním čase dějiště; interpretace v zóně serveru posunula zobrazené časy o 1–2 hodiny a oprava si vyžádala samostatnou vrstvu pro práci s „naivními" časy.
- Rozhodnutí o zaokrouhlování EUR vyřešit dřív. Cent oproti celému euru je maličkost, dokud na ni nenarazí pokladna.
Potřebujete přestavět e-shop nebo napojit platby a feedy bez výpadku byznysu?
Probereme rozsah, rizika a nejbezpečnější cestu migrace — bez závazku.
Napište nám →