Preconditions
- Inherits public-event-page trunk: page resolves; not archived.
- Event has at least one Access Type with
distribution = "invite",transaction_type = "purchase", and a positiveprice. - Guest has a valid invitation token tied to that Access Type.
- Tenant has Stripe credentials configured.
- EF-017 contracts apply (Stripe Elements, idempotency, 3DS, webhook source-of-truth) — this story does NOT re-test them; it inherits via reference and tests only the invitation-token interactions.
Happy path
-
Guest clicks the invitation link from email.
URL is
/p/:slug?invite_token=:token. Server-side, the token is resolved before rendering: validates signature, checks expiry, checks not-yet-used, looks up the access type. If valid, the page renders the invitation context (guest's pre-filled name + email from the invitation, the gated Access Type's price, expires_at countdown). -
Guest sees the modal pre-populated.
The same modal as EF-017, opened automatically with identity pre-filled (read-only for email since that's the invitation key; editable for everything else). A subtle "Invitation from [Organizer Name]" header strip distinguishes it from a public-purchase flow.
-
Pay via Stripe.
Inherits EF-017 mechanics. The PaymentIntent is created with a metadata field
invitation_token: <token>so Stripe records the invitation linkage. The token is marked as "in use" server-side (status=consumed, with a 30-min lock) when the PaymentIntent is created. -
Webhook confirms; token transitions to "used".
On charge.succeeded webhook, the registration confirms AND the invitation token transitions
consumed → used. Audit log row references the token ID + the registration ID.
Failure modes
Token revoked mid-checkout
Trigger: guest opens invite link, fills card, submits. Between the page load and submit, organizer revokes the token (e.g., guest is no longer welcome).
Submit-time check: server validates token is still active. If revoked, returns 410 GONE with body { error: "INVITATION_REVOKED" }. Modal surfaces "This invitation is no longer valid. Contact the event organizer." No PaymentIntent is created. Identity fields preserved. Harness: revoke token after page load, dispatch submit, assert 410, assert no Stripe PaymentIntent created.
Token already used on a different card (replay attack)
Trigger: token was successfully used 5 minutes ago. Now another submit arrives with the same token.
Submit-time check: token status=used returns 410 GONE with body { error: "INVITATION_ALREADY_USED" }. UI shows "This invitation has already been used. If you didn't use it, contact support." Harness: simulate prior successful use, dispatch a second submit, assert 410.
Token expired between landing and submit
Trigger: guest opens invite at 5:55 PM, expires_at is 6:00 PM, submits at 6:01 PM.
Submit-time check: token expires_at < now returns 410 GONE with body { error: "INVITATION_EXPIRED" }. UI shows "This invitation has expired" + a "Request a new invitation" CTA that opens a contact-form mailto link to the organizer (not auto-extending — that's an organizer decision). Harness: stub Date.now to past expires_at, dispatch submit, assert 410.
Token consumed-but-payment-failed → token re-released after timeout
Trigger: guest creates PaymentIntent (token marked consumed), payment fails (3DS reject), guest abandons. Token shouldn't be permanently locked.
Consumed tokens have a 30-minute "lock". If no successful charge lands within 30 min, a scheduled job releases the token back to active. The same guest (same email key) can retry within or after the lock — the lock applies to OTHER attempts. Harness: simulate consumed-then-payment-fail, wait 30+ min OR trigger the cleanup job, assert token status=active.
Concurrent token use — two browsers, same invite
Trigger: guest opens the invite on phone AND laptop, fills both, submits both within seconds.
Server-side row-level lock on the token row prevents two concurrent successful registrations. The first submit's PaymentIntent creation locks the token. The second submit returns 409 INVITATION_LOCKED. UI on the second submitter shows "This invitation is being processed in another window. Please complete in that window." Harness: dispatch two submits within 100ms, assert exactly one returns 200, the other returns 409.
Transferable token passed to someone else
Trigger: organizer marked the access type transferable; original recipient forwarded the email; new recipient opens the link.
Token validates regardless of who opens it (transferable=true). The pre-filled identity comes from the original invitation BUT the form is editable. If the new submitter changes the email, the token's "consumed_by_email" field is updated to reflect the actual purchaser. The original recipient gets a courtesy email "Your invitation was used by [new email]." Harness: token marked transferable, submit with different email, assert transfer-event audit row.
Non-transferable token passed to someone else
Trigger: organizer marked the access type non-transferable; original recipient forwarded; new recipient submits.
Submit-time check: identity email vs token's invitee_email. Mismatch returns 403 NON_TRANSFERABLE with UI "This invitation is non-transferable and was issued for [first 3 chars]@[domain]." Anti-probing: error message reveals only the first 3 chars + domain (proves to the legitimate recipient that it's their invite, no leak to attackers). Harness: non-transferable token + different email, assert 403, assert revealed email portion is exactly first-3-chars-only.
Invite-only price differs from public price
Trigger: same event has a public Access Type at $200 and an invite-only Access Type at $150. Guest's invitation is for the $150 one.
Modal shows the access-type-specific price ($150) NOT the public price. The invitation cannot be used to pay the $150 against the $200 access type and vice versa. Harness: guest with $150 invite token submits, assert PaymentIntent amount = 15000 cents, NOT 20000.
Capacity-full on the access-type-specific allocation
Trigger: invite access type has its own capacity cap (e.g., "20 invited guests"). 20 already confirmed. New invite-purchase submits.
Two-tier capacity check: event-level capacity AND access-type-level capacity. Submit fails 409 ACCESS_TYPE_SOLD_OUT with UI "This invitation tier is fully booked. Please contact the organizer." NO charge attempted. Harness: stub access-type capacity at 20/20, submit, assert 409 with that error code.
Cross-tenant token forgery
Trigger: attacker forges or replays a token from tenant A on tenant B's event URL.
Token signature validates against tenant-scoped HMAC secret. Cross-tenant token validation fails. Returns 404 (NOT 401 or 403, to avoid existence-leak). UI shows generic "Invitation not found." Harness: forge a token signed with wrong tenant secret, submit, assert 404 + no Stripe interaction.
Token leak in console / network logs
Trigger: client-side accidentally logs the token in console.error or includes it in a third-party tag (analytics).
Token must never appear in: console.*, document.referrer-equivalent third-party requests, error reporting payloads (Sentry must scrub), GET request URLs after initial landing (use POST + body instead). Harness: console-clean asserts no token-shaped string in console; pageEvaluation asserts no third-party request URL contains the token.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
invite-purchase-modal | Modal in invite-purchase mode | data-flow=invite-purchase |
invite-purchase-organizer-strip | Header strip | "Invitation from [Organizer]" label |
invite-purchase-prefilled-email | Read-only email field | Pre-populated; editable only for transferable tokens |
invite-purchase-amount | Price display | Access-type-specific price (NOT public price) |
invite-purchase-expires-at | Countdown | Visible if token expires in <24h |
invite-revoked-message | Visible after 410 INVITATION_REVOKED | "This invitation is no longer valid" |
invite-already-used-message | Visible after 410 INVITATION_ALREADY_USED | "This invitation has already been used" |
invite-expired-message | Visible after 410 INVITATION_EXPIRED | "This invitation has expired" |
invite-locked-message | Visible after 409 INVITATION_LOCKED | "Being processed in another window" |
invite-non-transferable-message | Visible after 403 NON_TRANSFERABLE | Reveals only first 3 chars + domain |
invite-access-type-sold-out | Visible after 409 ACCESS_TYPE_SOLD_OUT | "This invitation tier is fully booked" |
Agent test plan
Probe list
- modal-opens-with-token: navigate /p/:slug?invite_token=:valid, modal auto-opens, organizer-strip visible
- prefilled-email-readonly: non-transferable token, email field has readonly attribute
- prefilled-email-editable-transferable: transferable token, email field editable
- price-is-access-type-specific: invite to $150 access type for event with public $200, amount displays $150
- happy-path-token-consumed: complete payment, token transitions consumed → used, audit log row references both
- token-revoked-mid-checkout: revoke after load, submit returns 410, no PaymentIntent created
- token-already-used: prior successful use, second submit returns 410
- token-expired: stub past expires_at, submit returns 410
- token-consumed-released-after-fail: payment fails, after 30+min cleanup token returns to active
- concurrent-token-use: two submits 100ms apart, exactly one 200 + one 409
- transferable-token-different-email: transfer-event audit row created
- non-transferable-different-email: 403 with first-3-chars-only revealed
- access-type-capacity-full: stub access-type 20/20, submit 409 ACCESS_TYPE_SOLD_OUT
- cross-tenant-token-forgery: forged token, response 404 (not 401/403)
- no-token-in-console: console-clean asserts no token-shaped string
- no-token-in-third-party-requests: pageEvaluation asserts no third-party URL contains token
- audit-log-references-token-and-registration: confirmed flow has audit row with both ids