Dva platební webhooky závodí — idempotency guard klíčovaný na sdílený stav spolkne notifikace
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Platba kartou projde, objednávka je „zaplaceno”, kupující i admin dostanou e-mail — ale prodejce/umělec ne. Nahodile: část objednávek notifikaci dostane, část ne (~50 %). V logu e-mailů u postižené objednávky chybí i „payment confirmed” kupujícímu (jen „new order” z času vytvoření).
Root cause
Sekce “Root cause”Pro jednu kartovou Stripe Checkout platbu Stripe pošle dva eventy: checkout.session.completed i payment_intent.succeeded. Pořadí doručení/zpracování NENÍ garantované.
checkout.session.completed= vlastník dokončení: označí paid, vygeneruje faktury, pošle notifikace (kupující + prodejce), gate na „už bylo zaplaceno?”:const alreadyPaid = prior?.payment_status === 'paid';...if (order?.customer_email && !alreadyPaid) sendOrderConfirmed(...)if (order?.id && !alreadyPaid) /* loop → sendArtworkSold(...) */payment_intent.succeeded= taky nastavípayment_status='paid', ale žádný e-mail neposílá (jen zaloguje).
Když payment_intent.succeeded vyhraje závod a označí paid dřív, pak checkout.session.completed vidí alreadyPaid=true a tiše přeskočí obě notifikace. Guard měl chránit před duplicitou při Stripe RETRY téhož eventu — jenže byl klíčovaný na payment_status, což je stav, který nastavuje i druhý, neposílající handler. Faktury/provize gate neměly, takže ty proběhly — zmizely jen e-maily.
Fix
Sekce “Fix”Gate notifikací klíčovat na email_log, ne na payment_status:
let alreadyNotified = false;if (order?.id) { const { data: priorMail } = await supabaseAdmin .from('email_log').select('id') .eq('order_id', order.id) .in('template_slug', ['order-confirmed','artwork-sold']).limit(1); alreadyNotified = !!(priorMail && priorMail.length);}if (order?.customer_email && !alreadyNotified) sendOrderConfirmed(...)if (order?.id && !alreadyNotified) /* → sendArtworkSold(...) */email_log odráží, jestli jsme reálně (aspoň pokus o) poslání udělali — je imunní vůči tomu, že druhý handler přehodí payment_status, a pořád dedupuje skutečné RETRY téhož eventu (ty chodí sekvenčně, ne paralelně). Notifikace nechat jen v JEDNOM handleru (checkout.session.completed), aby nevznikl cross-event concurrency double-send.
Náprava existujících: zjistit paid objednávky bez artwork-sold v email_logu a doslat. Re-trigger checkout.session.completed je bezpečný, jen když jsou všechny vedlejší efekty idempotentní — faktury (already exist) i provize galerie (unique source_type,source_id); jinak posílej e-maily přes manuální per-order endpoint, ne přes celý webhook.
Jak se tomu vyvarovat v jiných systémech
Sekce “Jak se tomu vyvarovat v jiných systémech”- Detection: porovnej počet zaplacených objednávek vs. počet odeslaných „prodej” notifikací; rozdíl = spolknuté. Hledej handlery, kde víc eventů zapisuje týž
status/paidflag, ale jen jeden posílá notifikace. - Anti-pattern: „už zpracováno?” odvozovat ze stavu, který umí nastavit víc různých zdrojů (payment_status, status=‘done’); předpoklad, že pro jednu platbu přijde jen jeden webhook.
- Lepší přístup: každý vedlejší efekt (e-mail, faktura, provize) dedupuj na vlastním artefaktu (řádek v email_logu / faktura se stejným stripe_invoice_id / unique constraint v ledgeru). Jeden vlastník notifikací. Při re-triggeru webhooku ověř idempotenci VŠECH větví, ne jen té, kterou opravuješ.
Sister bugs / související
Sekce “Sister bugs / související”Stejná rodina jako [[overbroad-webhook-skip-swallows-real-payment]] — obojí „webhook tiše spolkne kritický krok kvůli špatně zvolené podmínce”. Univerzální pravidlo: u peněz i notifikací nikdy nespoléhej na typ eventu ani na sdílený stav — dedupuj na potvrzeném artefaktu a měj reconciliation.