← All stories

BRANCH · ef-024-promo-codes

Promo Codes

EF-024 Persona: Organizer config + public guest redemption Stage: Public registration with payment Roots in: event-setup + public-event-page Matrix=Absent — entire feature ships as gap probe

Promo codes: discount, free-ticket, and hidden-ticket-reveal mechanics. The matrix marks this Absent — there is no implementation for promo-code validation or redemption. This story describes the desired contract; the parity-gap probe asserts a visible "EF-024 not yet implemented" panel until the feature ships. When code lands, the gap probe failure is the signal to tighten this story toward the deployed contract.

Preconditions

  • Organizer-side: tenant + event exist; organizer is the actor.
  • Public-side: inherits public-event-page trunk.
  • Promo-code feature flag is enabled at the tenant level (when implemented).
  • Inherits EF-017 payment contracts (Stripe Elements, idempotency, 3DS, webhook source-of-truth) for any code that touches a paid checkout.

Happy path (desired contract)

Organizer config side

  1. Organizer creates a promo code under Event Settings → Promo Codes.

    Form fields: code (alphanumeric, case-insensitive at validation, case-preserving for display), kind ("discount-percent" | "discount-fixed" | "free-ticket" | "reveal-hidden"), value (percent or amount in cents per access type), redemption_limit (total uses), redemption_per_user (per email), valid_from + valid_until timestamps, allowed_access_types (multi-select), hidden_access_types_revealed (for reveal-hidden kind).

  2. Code is unique within event scope.

    Two events can share a code string (no global uniqueness). Within one event, a duplicate-code submit returns 409 PROMO_CODE_DUPLICATE. Codes are case-insensitive at validation but stored case-preserving.

  3. Organizer can disable / delete a code.

    Disable transitions the code to status=disabled (existing pending checkouts using it can complete; new attempts reject). Delete is destructive (uses ui-destructive-confirmation) and removes the code from the lookup table; existing audit logs preserve the original code string.

Public guest side

  1. Guest enters a code in the registration modal.

    A "Have a promo code?" expandable input appears below the access-type selection. Guest types code, blurs (or hits a small Apply button). Server validates and returns the resulting state: original price, discount amount, final price, OR for reveal-hidden, the unlocked access types.

  2. Discounted total reflects in the modal.

    For percent-discount: $200 × 25% off = $50 discount; final $150. For fixed-discount: $200 - $25 = $175. For free-ticket: $0 final, ui-status-pill="Free" + payment iframe collapses (no card needed). For reveal-hidden: hidden access types list expands; guest selects one or more.

  3. Submit applies the discount.

    PaymentIntent amount reflects the discounted total. The Idempotency-Key includes the promo code hash so retries dedupe at the (session, code) level. On webhook success, the registration confirms AND a redemption row is written (promo_code_id + registration_id + amount_discounted).

Failure modes (desired contract)

Parity gap — feature absent

Trigger: matrix marks EF-024 absent.

Visible "EF-024 promo codes not yet implemented" panel on the event setup admin page. Guest-side: the "Have a promo code?" expandable does NOT render until the feature ships. The gap probe asserts the visible panel + asserts the guest-side input is NOT rendered. When the feature ships, this probe fails — that's the green-flag tightening signal.

Code unknown

Trigger: guest enters a code that doesn't exist for this event.

Server returns 404 PROMO_CODE_NOT_FOUND. UI shows generic "This code isn't valid for this event" — same response shape as expired/disabled codes (anti-probing). Harness: enter random string, assert 404, assert error message identical to the response for an expired code.

Code expired (outside valid_from / valid_until)

Trigger: code's valid_until is in the past, or valid_from is in the future.

Server returns 410 PROMO_CODE_EXPIRED. UI shows the same "This code isn't valid for this event" message as code-unknown. Harness: stub time to past valid_until, assert 410 (server-side; client UI message identical to 404 case).

Redemption limit exhausted (global)

Trigger: code has redemption_limit=100 and 100 redemptions have completed.

Server returns 410 PROMO_CODE_EXHAUSTED. UI: same generic "not valid" message. Anti-probing: revealing "this code is fully redeemed" leaks usage data. Harness: stub redemption count at limit, assert 410.

Per-user redemption limit hit

Trigger: code has redemption_per_user=1; same email already redeemed once.

Server returns 410 PROMO_CODE_USER_EXHAUSTED. UI: same generic message OR (since the user identity is already known via their email in the form) a friendlier "You've already used this code" — friendlier-message exception requires that we've already authenticated the email (post-payment, on retry-click). Soft-decision; default to generic. Harness: simulate prior redemption by same email, assert 410.

Code applies to wrong access type

Trigger: code's allowed_access_types includes only "VIP" but guest selected "General Admission".

Server returns 422 PROMO_CODE_NOT_APPLICABLE_TO_ACCESS_TYPE. UI shows "This code applies to: VIP" — telling the guest WHICH access type unlocks it (this is acceptable to surface since it's organizer-published policy, not user-specific data). Harness: code valid for VIP only, guest selects GA, assert 422.

Reveal-hidden code unlocks the right access types

Trigger: code is kind=reveal-hidden, hidden_access_types_revealed=["press-pass"]; before code, the press-pass access type is not visible in the modal.

Pre-code: ONLY public access types render in the modal. Post-valid-code: the hidden access types list expands; guest can now select press-pass. Server-side enforcement: a submit selecting press-pass WITHOUT the code returns 403 (anti-tampering on hidden access type IDs). Harness: confirm press-pass not visible without code; apply code; press-pass visible; submit without code returns 403.

Free-ticket code skips payment

Trigger: code is kind=free-ticket; final price is $0.

Stripe Elements iframe is hidden (no card needed). Submit creates a registration directly, bypasses PaymentIntent. Audit log row records "free-ticket via promo code [code-id]." Harness: apply free-ticket code, assert iframe not rendered, assert no Stripe API call, assert registration confirmed.

Code-then-3DS race

Trigger: guest applies code, fills card, submits. 3DS challenge opens. Mid-challenge, organizer disables the code.

Code validation happens at submit-time (PaymentIntent creation), not at confirm-time. Once the PaymentIntent is created with the discounted amount, disabling the code does NOT invalidate the in-flight charge. Server-side: PaymentIntent succeeds at the discounted price; redemption row is written. Harness: simulate code-disable mid-3DS, assert charge captures at discounted price.

Refund includes promo code metadata

Trigger: organizer refunds a registration that used a promo code.

Refund event audit references both the original charge AND the promo code redemption ID. Per-code redemption count decrements (refunded redemption frees up a slot for future use). Harness: refund a code-using registration, assert redemption row marked refunded, assert global redemption count decremented.

Cross-tenant code leak

Trigger: tenant A's code "EARLYBIRD" is entered on tenant B's event.

Code lookup is event-scoped (or tenant-scoped fallback). Cross-tenant entry returns 404 (anti-probing — same as unknown-code). Audit log captures the attempt for fraud monitoring. Harness: enter a known cross-tenant code, assert 404, assert no leak about its existence in tenant A.

Brute-force protection on code entry

Trigger: bot tries common codes ("DISCOUNT", "PROMO", "FREE", etc.) at high rate.

Per-IP rate-limit on /promo-codes/validate: 10 attempts per minute, then 429 with retry-after. Per-event rate-limit too (1000 attempts/hour summed across IPs) to detect distributed attacks. Audit log captures the attempts. Harness: dispatch 11 attempts in 60s, assert 429 on 11th.

Stable test attributes

data-testWherePurpose
ef024-gap-panelEvent Settings → Promo Codes admin tabVisible until feature ships; anchors the parity-gap probe
promo-code-listAdmin: configured codesUses ui-data-table; per-row redemption count + status pill
promo-code-create-formAdmin: create formCode, kind, value, limits, validity window, allowed access types
promo-code-disableAdmin: per-row disableTransitions code to disabled (audit row)
promo-code-deleteAdmin: per-row deleteUses ui-destructive-confirmation
promo-code-inputPublic guest modal: code inputVisible only when feature is enabled at tenant level
promo-code-apply-buttonPublic guest modalSubmits code for validation
promo-code-discount-displayPublic guest modal"$50 off" or "Free" pill
promo-code-errorPublic guest modalGeneric "not valid for this event" message
promo-code-revealed-access-typesPublic guest modalVisible only after reveal-hidden code applies

Agent test plan

Probe list
- gap-panel-visible: matrix=Absent, ef024-gap-panel visible on admin Promo Codes tab
- code-input-not-rendered-public: matrix=Absent, promo-code-input NOT rendered in public modal
- (when shipped) code-creation-validates-uniqueness: duplicate code in same event returns 409
- (when shipped) code-creation-cross-event-allowed: same code in two different events succeeds
- (when shipped) code-unknown-generic-message: enter random, response 404, message matches expired-case message
- (when shipped) code-expired-same-message: stub past valid_until, response 410, message identical to unknown-case
- (when shipped) global-limit-exhausted: stub redemption count = limit, response 410, generic message
- (when shipped) per-user-limit-hit: same email prior redemption, response 410
- (when shipped) wrong-access-type: VIP-only code on GA, response 422 with helpful message
- (when shipped) reveal-hidden-unlocks: pre-code press-pass not visible; post-code press-pass visible
- (when shipped) hidden-without-code-403: submit press-pass without code, server returns 403
- (when shipped) free-ticket-no-stripe: kind=free-ticket, no Stripe API call, registration confirmed
- (when shipped) code-disable-mid-3ds-no-charge-revert: charge succeeds at discounted price
- (when shipped) refund-decrements-redemption: refund a code-using registration, redemption count goes down
- (when shipped) cross-tenant-code-leak: tenant A code on tenant B event returns 404
- (when shipped) brute-force-rate-limit: 11 attempts in 60s, 11th returns 429
- (when shipped) audit-log-redemption: confirmed code-using registration writes promo_code_redemption row
- (when shipped) admin-redemption-count-display: ui-data-table shows accurate redemption count per code