feat(events): promo codes — organizer editor and server-validated checkout discount #153

Merged
padreug merged 6 commits from feat/events-promo-codes into dev 2026-09-13 16:55:15 +00:00
Owner

Webapp half of promo codes (backend: aiolabs/events#45, v1.6.1-aio.12). Either side can ship first: the extension keeps stored codes when an update omits them, and this UI degrades against an older extension (validate route missing → "could not verify, applied at checkout", code still sent).

Organizer

  • New PromoCodesEditor.vue inside the create/edit dialog as a second collapsible ("Promo codes"): rows of code (uppercased as you type), discount % (1–100), max uses (blank = unlimited), active switch, remove; per-row validation (required, unique, ranges) via the pure lib/promoCodes.ts; Used n caption from the server-derived used_count. extra.promo_codes is always sent explicitly (empty editor = clear).
  • useMyEvents now lets the organizer's own authenticated record win over the public one on merge — required because public records no longer carry codes.
  • Scanner roster shows the redeemed code as a badge (applied_promo_code from stats).

Buyer

  • Promo field + Apply in the purchase dialog, backed by usePromoCode → POST /events/api/v1/promo/validate/{event_id} ({codes, quantity}); states idle / checking / applied / invalid / unverified; debounced re-validation on quantity change; stale responses ignored. Applied: struck-through original + server total + CODE −N% badge; the discounted total feeds the header, price row, fiat conversion preview and invoice amount.
  • Code passed on both rails; a 100 % code on the card rail no longer dead-ends ("no checkout URL") — paid: true goes straight to the success state. Zero total after a code routes through the free path; a guest whose total becomes zero gets an inline login prompt (events#29 posture) instead of the whole form swapping out. Promo-related 400s from purchase trigger a re-validate.
  • TicketApiService.request() now attaches status to thrown errors (additive).

i18n / tests

events.create.promo.* (new create namespace, only promo — the rest of the dialog stays hardcoded English as before) and events.purchase.promo.* in en/es/fr. vue-tsc -b clean; vitest 46/46 (20 new: helpers, validate body/URL, promo_code on requestTicket, extra.promo_codes on createEvent, 404 → status).

Manual verification after both deploy

Editor round-trip incl. max uses; Lightning + HALF → 500 sat invoice; Card + HALF → halved Stripe session with metadata.promo_code; third HALF → invalid on Apply / "fully redeemed" on forced purchase; guest + FREE100 → inline login prompt; logged-in + FREE100 on Card → immediate success; scanner badge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas

Webapp half of promo codes (backend: aiolabs/events#45, `v1.6.1-aio.12`). Either side can ship first: the extension keeps stored codes when an update omits them, and this UI degrades against an older extension (validate route missing → "could not verify, applied at checkout", code still sent). ## Organizer - New `PromoCodesEditor.vue` inside the create/edit dialog as a second collapsible ("Promo codes"): rows of code (uppercased as you type), discount % (1–100), max uses (blank = unlimited), active switch, remove; per-row validation (required, unique, ranges) via the pure `lib/promoCodes.ts`; `Used n` caption from the server-derived `used_count`. `extra.promo_codes` is always sent explicitly (empty editor = clear). - `useMyEvents` now lets the organizer's own authenticated record win over the public one on merge — required because public records no longer carry codes. - Scanner roster shows the redeemed code as a badge (`applied_promo_code` from stats). ## Buyer - Promo field + Apply in the purchase dialog, backed by `usePromoCode` → `POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}`); states idle / checking / applied / invalid / unverified; debounced re-validation on quantity change; stale responses ignored. Applied: struck-through original + server total + `CODE −N%` badge; the discounted total feeds the header, price row, fiat conversion preview and invoice amount. - Code passed on both rails; a 100 % code on the card rail no longer dead-ends ("no checkout URL") — `paid: true` goes straight to the success state. Zero total after a code routes through the free path; a **guest** whose total becomes zero gets an inline login prompt (events#29 posture) instead of the whole form swapping out. Promo-related 400s from purchase trigger a re-validate. - `TicketApiService.request()` now attaches `status` to thrown errors (additive). ## i18n / tests `events.create.promo.*` (new `create` namespace, only promo — the rest of the dialog stays hardcoded English as before) and `events.purchase.promo.*` in en/es/fr. `vue-tsc -b` clean; vitest 46/46 (20 new: helpers, validate body/URL, `promo_code` on requestTicket, `extra.promo_codes` on createEvent, 404 → status). ## Manual verification after both deploy Editor round-trip incl. max uses; Lightning + `HALF` → 500 sat invoice; Card + `HALF` → halved Stripe session with `metadata.promo_code`; third `HALF` → invalid on Apply / "fully redeemed" on forced purchase; guest + `FREE100` → inline login prompt; logged-in + `FREE100` on Card → immediate success; scanner badge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Types follow upstream v2 names (PromoCode.max_uses / used_count,
PromoValidateRequest, BasketDiscount, BasketTotals) as served by the
events extension >= v1.6.1-aio.12; EventExtra.promo_codes becomes
optional because public event responses no longer carry it.

TicketApiService.validatePromoCode posts the upper-cased code + quantity
to POST /events/api/v1/promo/validate/{event_id}; request() now attaches
the HTTP status to thrown errors so a 404 from an older extension can be
told apart from a 400. lib/promoCodes.ts holds the pure helpers shared by
the organizer editor and the buyer dialog (normalise, editor validation
returning i18n keys, wire <-> row mapping, display-only percent rounding).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
New events.create.promo.* (first key under a create namespace; the rest
of the create dialog remains hardcoded English) and events.purchase.promo.*
in en/es/fr + the types interface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
The authenticated fetch returns the full Event (incl. extra.promo_codes,
which public responses omit as of events ext v1.6.1-aio.12) and is what
the edit dialog must prefill from. Public records still win for events the
user does not own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Collapsible "Promo codes" section: rows of code (upper-cased as typed),
discount %, max uses (blank = unlimited), active switch, remove; per-row
errors from validatePromoRows gate submit alongside the vee-validate form.
extra.promo_codes is always sent explicitly (empty editor = clear); edit
mode prefills from the authenticated record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
usePromoCode drives an Apply flow against the validate endpoint (states
idle / checking / applied / invalid / unverified; quantity changes
re-validate with a debounce; stale responses are ignored; 404 from an
older extension keeps the code and sends it unverified). The dialog shows
the struck-through original and the discounted total in the header, price
block, conversion preview and invoice amount; the code rides on both the
Lightning and card requests. A 100 %-off code on the card rail lands on the
success screen via finalizePurchasedTickets instead of the "no checkout URL"
error, and a guest whose total becomes zero gets an inline log-in prompt
(free claims stay login-gated, events#29) rather than a swapped form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Maps applied_promo_code from the stats endpoint onto the roster rows as a
small outline badge and includes it in the fuzzy search keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
padreug deleted branch feat/events-promo-codes 2026-09-13 16:55:15 +00:00
Sign in to join this conversation.
No description provided.