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
-
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).
-
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.
-
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
-
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.
-
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.
-
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-test | Where | Purpose |
|---|---|---|
ef024-gap-panel | Event Settings → Promo Codes admin tab | Visible until feature ships; anchors the parity-gap probe |
promo-code-list | Admin: configured codes | Uses ui-data-table; per-row redemption count + status pill |
promo-code-create-form | Admin: create form | Code, kind, value, limits, validity window, allowed access types |
promo-code-disable | Admin: per-row disable | Transitions code to disabled (audit row) |
promo-code-delete | Admin: per-row delete | Uses ui-destructive-confirmation |
promo-code-input | Public guest modal: code input | Visible only when feature is enabled at tenant level |
promo-code-apply-button | Public guest modal | Submits code for validation |
promo-code-discount-display | Public guest modal | "$50 off" or "Free" pill |
promo-code-error | Public guest modal | Generic "not valid for this event" message |
promo-code-revealed-access-types | Public guest modal | Visible 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