Compare commits

..

3 commits

Author SHA1 Message Date
f019011223 docs: promo-code contract; bump 1.6.1-aio.12
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:19:45 +02:00
46b58b95e5 feat(admin): max uses column + used count in the promo editor
The Quasar editor gains a "Max uses" input per code (blank = unlimited,
normalised to null on save) and shows "Used n / max" from the derived
count. The public buy page's Clear button now also clears the promo field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:19:44 +02:00
a685393298 feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public
Promo handling was inherited from upstream unchanged and had four gaps
the webapp was about to put in front of buyers:

- `active` was decorative: purchase never read it, so a deactivated code
  kept discounting. Now rejected with "Promo code is not active."
- No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with
  `used_count` DERIVED from paid tickets carrying the code in
  `extra.applied_promo_code` — each ticket of a multi-ticket purchase
  consumes one use (upstream v2 counts one per basket; documented).
  Paid-only counting so an abandoned Stripe session can't lock out the
  last uses for the 24 h unpaid-row lifetime; bounded overshoot under
  concurrency accepted.
- Every code was readable by anyone: `PublicEvent.extra` was the full
  `EventExtra` and `/events/public` returned the untrimmed `Event`
  (wallet id included). `EventExtraBase` / `PublicEventExtra` project
  them out; `/public` now goes through `PublicEvent`. Organizer and admin
  listings keep the full model, now hydrated with `used_count`.
- No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as
  upstream v2; `quantity` replaces v2's `items` since this fork has no
  ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory:
  bad codes are simply absent from `discounts_applied`; purchase still
  hard-fails them with distinct details.

All pricing (validate, invoice, Stripe amount) goes through one pure
`basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so
the preview equals the charge. Stripe metadata carries `promo_code`; the
organizer stats rows carry `applied_promo_code`.

`api_event_update` keeps stored codes when the request omits
`extra.promo_codes` (explicit `[]` still clears): now that public
records don't carry them, a client round-tripping one would otherwise
wipe the organizer's codes on every edit.

Closes #32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:19:44 +02:00

View file

@ -721,14 +721,14 @@ async def api_ticket_create(
detail="Promo code is not active.", detail="Promo code is not active.",
) )
usage = await event_promo_usage(event.id) usage = await event_promo_usage(event.id)
uses_left = remaining_uses(promo, usage.get(promo.code, 0)) remaining = remaining_uses(promo, usage.get(promo.code, 0))
if uses_left is not None and uses_left < quantity: if remaining is not None and remaining < quantity:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=( detail=(
"Promo code has been fully redeemed." "Promo code has been fully redeemed."
if uses_left == 0 if remaining == 0
else f"Only {uses_left} use(s) left on this promo code." else f"Only {remaining} use(s) left on this promo code."
), ),
) )
extra["promo_code"] = promo.code extra["promo_code"] = promo.code