Preconditions
- EF-017 (public purchase) and EF-026 (refunds) contracts apply.
- Tenant has an email-sending channel configured.
- Customer has a valid email on the registration.
- R2 (or equivalent) storage available for archived receipt PDFs.
Happy path
-
charge.succeeded webhook → receipt enqueued.
After payment confirms, server enqueues a receipt-send job in the notification outbox. Receipt content: event name, date, location, line items (access type × quantity × price), Stripe fees passed through (if pass-through is enabled), tax breakdown, total, charge ID for support, customer email, organizer "from" branding.
-
Receipt rendered as both HTML email + PDF attachment.
HTML email is the primary surface (responsive, dark-mode-aware). PDF attachment is for accounting / tax purposes. Both are deterministic from the same source data — render the same content. PDF is also archived to R2 with a tenant-scoped signed URL.
-
Customer receives the receipt email.
Subject line: "Your receipt for [event name]." From: tenant's verified sending domain (or fallback default). Includes a "View receipt online" link to a stable per-registration URL (HMAC-signed; no auth required since the customer doesn't have an account).
-
Customer opens the online receipt URL.
Public per-registration page renders the same receipt. Includes a "Download PDF" CTA, support contact link, and (if applicable) a refund-status banner showing any refund history. URL is HMAC-signed against the registration ID + a per-tenant secret; URL never expires (receipts are forever).
-
Refund issued → refund receipt sent.
When EF-026 refund completes, a second email is enqueued: "Refund issued for [event name]." Includes refund amount, reason (organizer-supplied if any), reference to the original charge, expected timing for the refund to appear on the customer's statement (5-10 business days). The online receipt URL updates to show the refund history.
Failure modes
Email send failure does NOT roll back the charge
Trigger: charge succeeded, receipt enqueue succeeded, but email provider returns 503 on send.
Notification outbox tracks the failure with retry-with-backoff. Charge stays succeeded. Registration stays confirmed. The customer can still access their receipt via the registration confirmation email's link OR by contacting support with the charge ID. After 3 retries fail (over 24 hours), an alert fires for ops, AND the customer-facing UI on the public registration page shows a "We couldn't reach your email — copy your receipt link below" banner. Harness: stub email-send 503, assert registration confirmed, assert outbox row in retry-pending state, assert alert fires after 3 failures.
HTML email vs PDF content drift
Trigger: HTML and PDF render different totals (rounding, locale, fee inclusion).
Both surfaces render from one canonical receipt-data object. Round-trip test: serialize the data, render HTML and PDF, parse the totals from each, assert exact equality. Harness: synthetic charge with non-trivial amounts (multiple line items, tax), render both, OCR-extract or parse-extract the total from each, assert they match exactly (down to cents and currency symbol).
Online receipt URL is forgeable
Trigger: attacker guesses or brute-forces a receipt URL by trying registration IDs.
URL contains an HMAC signature using a per-tenant secret. Forged URLs return 404 (not 401 — anti-probing). The signature is validated server-side before any data is returned. Harness: forge a URL with a wrong signature, assert 404, assert no leak about whether the registration ID exists.
Refund receipt fires before refund webhook (race)
Trigger: organizer issues a refund. Server optimistically enqueues the refund receipt, but the Stripe webhook fails permanently.
Refund receipt is enqueued ONLY on charge.refunded webhook receipt, not on the optimistic refund-creation step. If the webhook never arrives, the refund row stays in pending state and no receipt fires. Operator dashboard surfaces stuck-refund alerts. Harness: stub refund created but webhook fails, assert no refund-receipt row in outbox until webhook arrives.
Online receipt URL leaks customer email in HTML/title
Trigger: organizer or attacker shares the online receipt URL; the page's HTML title or H1 contains the customer's email.
Online receipt page reveals: event name, date, line items, total, charge ID (last 4 chars only), refund status. It does NOT reveal: customer's full email (only first 3 chars + domain), full address, phone, or other PII fields. Document title is generic ("Receipt — [event name]"). Harness: load a known online receipt URL, assert no email-shaped string in document.title or H1 element.
Receipt link expired in customer's email client
Trigger: customer opens a 2-year-old confirmation email; clicks the receipt link.
Receipt URLs do NOT expire. Audit obligations require receipts to remain accessible for tax-relevant periods (typically 7 years). The HMAC signature uses a long-lived per-tenant secret rotated only via a coordinated migration. Harness: load a synthetic 2-year-old receipt URL, assert 200 (not 410 or 404).
Tenant secret rotation
Trigger: tenant rotates their HMAC secret (e.g., suspected compromise).
Old URLs continue to work for a grace period (default 90 days) via dual-secret validation. Operator dashboard surfaces "Old receipt URLs will stop working on [date]" + offers a "Re-send all receipts with new URLs" job. Harness: rotate secret, assert old URLs still resolve during grace, assert post-grace they 404.
PDF generation timeout
Trigger: PDF render times out (e.g., headless browser hangs on a complex charge).
Email send proceeds with HTML only (PDF as attachment is best-effort). Receipt page online still renders the HTML version. PDF render retries in background; once successful, the PDF is archived AND a follow-up "Your receipt PDF is now available" email is sent. If retry fails permanently, the customer-facing online receipt has a "Generate PDF" CTA that re-attempts on demand. Harness: stub PDF render timeout, assert HTML email sent without PDF, assert outbox has retry job for PDF.
Disputed charge — receipt updated
Trigger: customer disputed the charge; dispute is open.
Online receipt page shows an additional "This charge is currently disputed (case [stripe-dispute-id])" banner. Refund/dispute timing tooltip explains "If the dispute is resolved in your favor, the charge will remain. If resolved against the merchant, the charge is reversed and you'll see the refund on your statement." Harness: stub dispute.created webhook, load online receipt, assert dispute banner visible.
Receipt resend on demand
Trigger: customer didn't receive their receipt; contacts support.
Organizer (with finance role) can resend a receipt from the registration detail panel. New email is generated with the same content; uses idempotency-key derived from (registration_id, send_count) so a stuck retry doesn't create duplicates. Audit log row written ("receipt_resent_by_organizer"). Customer-facing online receipt page also has a "Email me this receipt" CTA on a small link below the receipt; uses rate-limit (1 per 5 min) to prevent abuse. Harness: organizer clicks resend, assert outbox row, assert audit row.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
online-receipt-page | Public receipt URL surface | HMAC-signed; no auth required |
online-receipt-event-name | Receipt page | Event name display |
online-receipt-line-items | Receipt page | Access type × quantity × price |
online-receipt-total | Receipt page | Charged total (with currency) |
online-receipt-charge-id | Receipt page | Last 4 chars only of Stripe charge ID |
online-receipt-refund-history | Receipt page | Visible only when refunds exist |
online-receipt-dispute-banner | Receipt page | Visible only when an open dispute exists |
online-receipt-pdf-cta | Receipt page | "Download PDF"; visible only when PDF is archived |
online-receipt-email-resend-cta | Receipt page | "Email me this receipt" — rate-limited |
online-receipt-pdf-pending | Receipt page | Visible while PDF is still generating |
admin-receipt-resend-button | Admin: registration detail panel | Finance role only |
admin-receipt-history | Admin: registration detail panel | List of every receipt sent for this registration |
customer-receipt-pending-banner | Public registration confirmation page | Visible if email send failed and we couldn't reach customer |
Agent test plan
Probe list
- happy-path-receipt-enqueued: charge.succeeded → outbox row exists for receipt
- email-send-failure-no-rollback: stub email 503, registration still confirmed, outbox retry-pending
- alert-after-3-failures: stub 3 consecutive 503s, assert ops alert fires
- html-pdf-content-equal: render both, parse totals, assert exact match
- online-receipt-forged-url-404: forge HMAC, request URL, assert 404
- refund-receipt-only-on-webhook: refund created but webhook fails, no refund-receipt outbox row
- online-receipt-no-email-leak: load receipt URL, assert document.title + H1 don't contain email
- receipt-url-no-expiry: synthetic 2-year-old URL, assert 200
- secret-rotation-grace: rotate secret, old URLs work for 90 days
- pdf-timeout-graceful: stub PDF render timeout, HTML email sent without PDF, retry queued
- dispute-banner-visible: stub dispute.created, load receipt, dispute banner visible
- admin-resend-creates-outbox-row: organizer clicks resend, outbox row + audit row created
- customer-resend-rate-limit: customer hits resend 6 times in 5 min, 6th returns 429
- pdf-archived-to-r2: PDF generation success, R2 object exists at expected path
- non-finance-role-no-resend: support role tries resend, button hidden + API 403
- audit-log-receipt-events: every receipt-send event writes an audit row