Preconditions
- Inherits from the public-event-page trunk: page resolves, isn't archived, isn't capacity-full at page-load time.
- Event has at least one Access Type with
transaction_type = "purchase"and a positiveprice. - Tenant has Stripe credentials configured (publishable key + webhook secret).
- Stripe Elements loads from
js.stripe.com(subresource integrity attribute set).
Happy path
-
Guest opens the registration modal from a paid Access Type CTA.
The same
ui-modalshape used by free public registration (EF-031), with an additional payment section anchored to a Stripe Elements iframe. The iframe is fromjs.stripe.comonly — never our origin (PCI scope avoidance). -
Guest fills identity fields + card.
Identity uses our `ui-text-input` components in our DOM. Card details are entered into the Stripe iframe; we never touch the PAN, CVV, or expiry. Stripe.js produces a
payment_methodtoken that we send to our server. -
Submit creates a PaymentIntent server-side, with idempotency key.
POST /v1/public/events/:eventId/registrations/purchasewithIdempotency-Key: <client-uuid>header (one per checkout session). Server creates a Stripe PaymentIntent (with the same idempotency key forwarded to Stripe), reserves a registration row inpendingstate, returns the PaymentIntent client_secret. -
Client confirms PaymentIntent via Stripe.js.
If 3DS is required, Stripe.js opens the challenge modal. Guest completes challenge. PaymentIntent transitions to
succeeded. Stripe sends a webhook to our/webhooks/stripeendpoint with the success event. -
Webhook flips registration to
confirmed.Webhook is signature-verified using the per-tenant webhook secret. Registration row transitions
pending → confirmed. An event_audit_log row is written with the Stripe charge ID, amount, and currency. A confirmation email enters the notification outbox. -
Guest sees success state in the modal.
Modal transitions to "Payment confirmed — receipt sent to your email" with the registration code and receipt link. Receipt includes line items, taxes/fees as applicable, charge ID for support reference.
Failure modes
Card declined → no charge → friendly retry
Trigger: card decline (insufficient funds, fraud rule, expired). PaymentIntent transitions to requires_payment_method.
The modal stays open with the card field cleared, decline reason surfaced inline (using Stripe's error code mapped to user-friendly copy), and a "Try a different card" CTA. Identity fields are preserved. Server-side: registration row stays in pending state, no audit log row is written for the decline (Stripe handles the audit), and after 30 minutes of pending the row is reaped via a scheduled cleanup. Harness: simulate a decline via Stripe test card 4000000000000002, assert no d1-row-exists for status=confirmed, modal still visible with error message visible.
3DS challenge: success path
Trigger: card requires authentication; Stripe.js opens the 3DS modal; guest completes the challenge.
Browser navigation away from the challenge (Esc on the iframe, parent-window close attempts) is blocked. After challenge success, PaymentIntent transitions to succeeded via Stripe.js callback AND the webhook. Registration row transitions to confirmed via webhook only — never via client callback alone (anti-tampering). Harness: simulate Stripe test card 4000002760003184 (3DS-required-success), assert webhook fires before registration shows as confirmed.
3DS challenge: fail path → no charge
Trigger: guest fails the 3DS challenge or cancels the bank's modal.
PaymentIntent transitions to requires_action back to requires_payment_method. No charge captured. Modal surfaces "Authentication failed — try again or use a different card." Registration row stays pending. Harness: simulate test card 4000008400001629 (authentication-required-fail), assert no successful charge, assert registration not confirmed, modal still visible.
Idempotent retry — no double charge
Trigger: guest submits, network blip, browser appears to hang; guest hits submit again.
The client-side checkout session uses one Idempotency-Key for its full lifetime (generated on modal open). Both submit attempts carry the same key. Server (and Stripe) dedupe — only one PaymentIntent is created, only one charge. The webhook fires exactly once. Harness: dispatch the same submit twice within 1 second, assert api-call count for POST /registrations/purchase is 2 (client retried) but d1-row-exists count for confirmed registrations is exactly 1.
Capacity full mid-checkout
Trigger: guest is mid-fill; another guest claims the last seat; this guest's PaymentIntent confirm attempts but our capacity check rejects.
Two-phase: (a) at submit-time, server reserves a "soft hold" on capacity for 5 minutes (registration row in pending blocks the seat); (b) if capacity-full BEFORE soft-hold (because someone else just got it), submit returns 409 SOLD_OUT before any PaymentIntent is created — no charge attempted. If soft-hold-expired AFTER PaymentIntent created but before webhook arrives, the webhook handler reverses the charge (refund) and the registration enters refunded state. Harness: simulate concurrent capacity claim, assert no double-allocation.
Partial refund balances correctly
Trigger: organizer issues a $50 partial refund on a $200 ticket purchase.
Refund creates a Stripe refund object referencing the original charge. Our DB stores: refund_amount_cents = 5000, refund_balance_cents = 15000 (remaining refundable). Subsequent refund attempts cap at the remaining balance. Audit log row per refund. Harness: organizer issues 5000-cent refund, then attempts 20000-cent refund, assert second is rejected with `REFUND_EXCEEDS_BALANCE`.
Dispute (chargeback) lifecycle visible
Trigger: cardholder disputes the charge with their bank; Stripe sends a charge.dispute.created webhook.
The dispute is stored as a charge_disputes row with the Stripe dispute ID, reason, status, and due-by date. Organizer sees a "Disputed" status pill on the registration in admin (uses ui-status-pill registry — should add disputed to the registry as a tightening). Organizer can submit evidence via Stripe dashboard (we don't replicate that UX — link out). When dispute resolves (won or lost), the webhook updates the row. Harness: simulate dispute.created webhook, assert row exists; simulate dispute.closed webhook, assert row updated.
Receipt delivery failure does NOT roll back the charge
Trigger: payment confirms successfully; receipt-email enqueue or send fails (email provider 503).
The charge is captured. The registration is confirmed. The receipt failure is tracked in the notification outbox (status=failed, retryable). UI tells the guest "Payment confirmed. We'll email your receipt shortly — if it doesn't arrive, contact support with confirmation code XYZ." Never roll back the registration on a receipt failure. Harness: stub email-send 503, assert registration confirmed AND notification_outbox has retry-pending row.
Currency / locale display
Trigger: event price is GBP; guest is in a US locale; price needs to render correctly in both.
Server stores price as amount_cents + currency_iso_4217. Display formats via Intl.NumberFormat using the event's currency (NOT the guest's locale's currency — never silently convert). The guest sees "£25.00" with a small "(approximately $32.00 USD at today's rate)" hint if the guest's locale differs. Charge is in event currency. Harness: GBP event + US locale, assert displayed price contains "£" and the amount, NOT a USD-converted figure as the primary.
Stripe Elements iframe fails to load
Trigger: js.stripe.com blocked by ad-blocker, corporate proxy, or network failure.
Modal detects the iframe load failure (timeout 5s) and surfaces "Card entry unavailable — please disable ad-blockers or try a different network. Contact support for an offline payment option." Submit button stays disabled. No fallback to capturing card data on our origin (PCI scope). Harness: stub js.stripe.com load to fail, assert iframe-load-error message visible AND submit-disabled.
Webhook arrives before client callback (race)
Trigger: Stripe webhook hits our server before Stripe.js confirmation callback returns to the browser.
The webhook is the source of truth for registration state. The client callback's role is purely UX (transition the modal). If the client polls for registration status after submit and the webhook has already landed, the poll returns confirmed and the modal transitions to success. If the client callback says success but the webhook hasn't landed yet, the modal shows "Confirming payment..." with a spinner, polls every 1s for up to 15s, and only transitions to success when the server confirms. Never trust the client callback alone. Harness: simulate webhook-before-callback, assert UI converges to success without race.
Cross-tenant isolation on Stripe credentials
Trigger: tenant A's checkout flow accidentally references tenant B's Stripe publishable key (config bug or attack).
Public event page's checkout flow reads tenant-scoped config via the event's tenant_id. The publishable key in the page HTML is computed per-event (not a global). Server-side, the PaymentIntent is created using the tenant's secret key (looked up via event's tenant_id, never client-provided). Cross-tenant attempts are detected at the events table join: an event_id from tenant B with credentials from tenant A returns 404 from our public registration endpoint. Harness: forge a request with cross-tenant event_id, assert 404 (not a leaked Stripe key in any error path).
Stable test attributes
Visibility teeth. Each attribute must be effectively visible when its applicable state is active.
| data-test | Where | Purpose |
|---|---|---|
purchase-cta | Public event page | Opens registration modal in purchase mode |
purchase-modal | The ui-modal instance | Modal in purchase mode (data-flow=purchase attr) |
purchase-identity-form | Inside modal | Identity fields region |
purchase-stripe-iframe | Stripe Elements iframe | Card entry; src must be js.stripe.com |
purchase-stripe-iframe-load-error | Visible if iframe fails to load | "Card entry unavailable" copy |
purchase-amount | Inside modal | Currency-formatted price + tax breakdown |
purchase-submit | Inside modal | Disabled until identity + card valid + iframe loaded |
purchase-confirming-spinner | Visible while polling for webhook | "Confirming payment..." state |
purchase-success | Visible after webhook + confirmation | Registration code + receipt link + charge ID |
purchase-decline-reason | Visible after card decline | User-friendly decline message |
purchase-3ds-required | Visible while 3DS challenge open | "Authenticate with your bank" copy |
purchase-3ds-fail | Visible after 3DS fail | "Authentication failed" copy |
purchase-sold-out | Visible if capacity full at submit | "Sold out" + waitlist conversion offer |
purchase-currency-display | Inside ui-text region | Event currency formatted via Intl.NumberFormat |
Agent test plan
Standalone probes against the public event page with paid Access Types. Stripe is in test-mode for all probes; specific test card numbers exercise specific failure paths.
Probe list
- purchase-modal-opens: click purchase-cta, purchase-modal visible
- stripe-iframe-loads: iframe src starts with https://js.stripe.com
- iframe-load-failure-graceful: stub js.stripe.com to fail, purchase-stripe-iframe-load-error visible, purchase-submit disabled
- happy-path-success: test card 4242424242424242, full flow, purchase-success visible with charge ID
- card-declined: test card 4000000000000002, purchase-decline-reason visible, no confirmed registration row
- 3ds-required-success: test card 4000002760003184, 3DS modal appears, success path completes
- 3ds-required-fail: test card 4000008400001629, 3DS fail, no charge
- idempotent-retry: dispatch submit twice within 1s with same Idempotency-Key, assert exactly 1 confirmed row
- capacity-full-pre-submit: simulate capacity claim by another guest, submit returns 409 SOLD_OUT, no PaymentIntent created
- partial-refund-balance: organizer refunds $50 of $200, then attempts $200 refund, second rejected REFUND_EXCEEDS_BALANCE
- dispute-webhook-creates-row: simulate charge.dispute.created webhook, charge_disputes row exists
- receipt-failure-no-rollback: stub email send 503, registration still confirmed, notification_outbox has retry-pending row
- currency-display-event-currency: GBP event in US locale, displayed amount uses £, not USD primary
- webhook-before-callback: simulate webhook race, UI converges to success without double-state
- cross-tenant-isolation: forge cross-tenant event_id, response is 404 with no Stripe key leak
- audit-log-charge-id: confirmed registration writes event_audit_log with stripe_charge_id
- audit-log-refund: refund issued writes event_audit_log with refund amount + reason