← All stories

BRANCH · ef-019-invite-purchase

Invite to Purchase Access Type

EF-019 Persona: Invited guest Stage: Private paid invitation Roots in: public-event-page

Private paid invitation: a guest receives an email with an invitation token, lands on the event page in invite-purchase mode, sees the price (which may differ from any public price for the same event), and pays. The flow inherits EF-017's payments mechanics and EF-018's invitation-token mechanics — the new failure modes are the intersections (token-revoked-mid-checkout, token-already-used-on-different-card, transferable-token-passed-to-someone-else). Tier-3 tightening: the invite purchase lifecycle now references ui-public-form-flow while payment-specific behavior remains separate.

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 positive price.
  • 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

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

  2. 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.

  3. 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.

  4. 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-testWherePurpose
invite-purchase-modalModal in invite-purchase modedata-flow=invite-purchase
invite-purchase-organizer-stripHeader strip"Invitation from [Organizer]" label
invite-purchase-prefilled-emailRead-only email fieldPre-populated; editable only for transferable tokens
invite-purchase-amountPrice displayAccess-type-specific price (NOT public price)
invite-purchase-expires-atCountdownVisible if token expires in <24h
invite-revoked-messageVisible after 410 INVITATION_REVOKED"This invitation is no longer valid"
invite-already-used-messageVisible after 410 INVITATION_ALREADY_USED"This invitation has already been used"
invite-expired-messageVisible after 410 INVITATION_EXPIRED"This invitation has expired"
invite-locked-messageVisible after 409 INVITATION_LOCKED"Being processed in another window"
invite-non-transferable-messageVisible after 403 NON_TRANSFERABLEReveals only first 3 chars + domain
invite-access-type-sold-outVisible 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