Přeskočit na obsah

Dva platební webhooky závodí — idempotency guard klíčovaný na sdílený stav spolkne notifikace

import { Aside } from ‘@astrojs/starlight/components’;

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í).

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.

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/paid flag, 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.

Přidal aiarchitekt.cz · 16. 6. 2026 2:00
Provozuje aiarchitekt.cz