RedHand Case studies

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.

sportactions.cz
Homepage SportActions.cz — hero s nabídkou sportovních a kulturních zážitků, přepínač jazyka CS/EN a měny CZK/EUR v headeru

Obor

E-commerce · ticketing · vstupenky na sportovní eventy

Rozsah

Migrace legacy → nová platforma · platby · feedy · dvě měny · i18n · admin

Stack

Next.js 15 · React 19 · Drizzle ORM · PostgreSQL 16 · Redis · ČSOB · iDoklad · OpenAI

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.

Architektura SportActions v2 Zákazník a administrace přistupují přes storefront a admin do centrální doménové logiky, která ukládá data do PostgreSQL a Redis, komunikuje s platební bránou ČSOB (platba a idempotentní ověřený callback), po zaplacení volá iDoklad pro fakturaci a odesílá e-maily. Dodavatelské feedy synchronizují katalog cronem do doménové logiky s ochranou ručně nastavených cen. cron sync, chrání ruční ceny platba idempotentně po zaplacení Zákazník (web CS / EN) Administrace katalog · ceny · objednávky Dodavatelské feedy 4 zdroje vstupenek Storefront Next.js 15 Doménová logika objednávky · ceny · překlady PostgreSQL + Redis ČSOB platební brána iDoklad automatická faktura E-maily potvrzení · vstupenky PDF
Storefront, admin a feedy sbíhají do jedné doménové vrstvy (zvýrazněno) — pravidla objednávek, cen a plateb žijí na jednom místě.

Rozhodnutí a trade-offy

RozhodnutíAlternativaProč 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.

ts
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 Eventy podle zdroje feedu (4 599 celkem, 66 kategorií) — katalog plní feedy, ne ruce. Názvy dodavatelů anonymizovány. Dodavatel A 2 733 Dodavatel B 1 730 Dodavatel C 66 Dodavatel D 66 Ručně 4

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í 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. 33 ’23 Q4 31 ’24 Q1 16 ’24 Q2 21 ’24 Q3 52 ’24 Q4 19 ’25 Q1 10 ’25 Q2 10 ’25 Q3 7 ’25 Q4 7 ’26 Q1 37 ’26 Q2 8 ’26 Q3
Legacy (PrestaShop, původní datum) Nová platforma

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ákazníků Kumulativní počet zákaznických účtů podle roku registrace — historie od ledna 2016 přenesena celá. 1 500 0 20162017201820192020202120222023202420252026 1 065 1 449

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 →