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
This commit is contained in:
parent
4c3b1bca31
commit
8602bd71e3
7 changed files with 618 additions and 19 deletions
86
views_api.py
86
views_api.py
|
|
@ -59,10 +59,12 @@ from .crud import (
|
|||
update_ticket,
|
||||
)
|
||||
from .models import (
|
||||
BasketTotals,
|
||||
CreateEvent,
|
||||
CreateTicket,
|
||||
Event,
|
||||
EventsSettings,
|
||||
PromoValidateRequest,
|
||||
PublicEvent,
|
||||
PublicTicket,
|
||||
Ticket,
|
||||
|
|
@ -71,6 +73,13 @@ from .models import (
|
|||
effective_payment_methods,
|
||||
)
|
||||
from .nostr_hooks import publish_or_delete_nostr_event
|
||||
from .promo import (
|
||||
basket_totals,
|
||||
find_promo,
|
||||
normalize_code,
|
||||
remaining_uses,
|
||||
round_amount,
|
||||
)
|
||||
from .qr import (
|
||||
image_png_bytes,
|
||||
load_qr_logo,
|
||||
|
|
@ -79,6 +88,8 @@ from .qr import (
|
|||
ticket_card_filename,
|
||||
)
|
||||
from .services import (
|
||||
event_promo_usage,
|
||||
hydrate_promo_usage,
|
||||
refund_tickets,
|
||||
resend_ticket_email_notification,
|
||||
send_ticket_notification_in_background,
|
||||
|
|
@ -89,6 +100,7 @@ from .tasks import deregister_payment_listener, register_payment_listener
|
|||
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||
qr_api_router = APIRouter(prefix="/api/v1")
|
||||
promo_api_router = APIRouter(prefix="/api/v1/promo")
|
||||
|
||||
|
||||
def _is_fiat_currency(currency: str | None) -> bool:
|
||||
|
|
@ -108,12 +120,17 @@ async def api_events(
|
|||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
return await get_events(wallet_ids)
|
||||
events = await get_events(wallet_ids)
|
||||
for event in events:
|
||||
await hydrate_promo_usage(event)
|
||||
return events
|
||||
|
||||
|
||||
@events_api_router.get("/public")
|
||||
@events_api_router.get("/public", response_model=list[PublicEvent])
|
||||
async def api_events_public() -> list[Event]:
|
||||
"""Approved, non-canceled events for an anonymous public listing."""
|
||||
"""Approved, non-canceled events for an anonymous public listing.
|
||||
|
||||
Projected through `PublicEvent`: no wallet id, no promo codes."""
|
||||
return await get_public_events()
|
||||
|
||||
|
||||
|
|
@ -131,6 +148,7 @@ async def api_events_all(
|
|||
events = await get_all_events()
|
||||
enriched: list[dict] = []
|
||||
for event in events:
|
||||
await hydrate_promo_usage(event)
|
||||
wallet = await get_wallet(event.wallet)
|
||||
row = event.dict()
|
||||
row["wallet_user_id"] = wallet.user if wallet else None
|
||||
|
|
@ -353,6 +371,13 @@ async def api_event_update(
|
|||
if not data.closing_date:
|
||||
data.closing_date = data.event_end_date
|
||||
|
||||
# Promo codes are organizer-only and absent from public responses, so a
|
||||
# client that round-trips a public record (or simply doesn't manage
|
||||
# codes) would otherwise wipe them on every edit. Carry the stored list
|
||||
# over unless the request names the key; an explicit `[]` still clears.
|
||||
if "promo_codes" not in data.extra.__fields_set__:
|
||||
data.extra.promo_codes = event.extra.promo_codes
|
||||
|
||||
# Explicit field list — never copy `status` from the request body.
|
||||
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
|
||||
# owner editing a fiat-enabled event keeps the fiat config.
|
||||
|
|
@ -660,7 +685,7 @@ async def api_ticket_create(
|
|||
name = data.name
|
||||
email = data.email
|
||||
user_id = data.user_id
|
||||
promo_code = data.promo_code.upper() if data.promo_code else None
|
||||
promo_code = normalize_code(data.promo_code)
|
||||
refund_address = data.refund_address
|
||||
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
|
||||
payment_method = (data.payment_method or "lightning").lower()
|
||||
|
|
@ -677,22 +702,39 @@ async def api_ticket_create(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid Nostr identifier.",
|
||||
) from exc
|
||||
unit_price = event.price_per_ticket
|
||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||
frontend_root = _resolve_frontend_root(data, request)
|
||||
|
||||
# One invoice, N tickets; the promo (if any) prices the whole quantity
|
||||
# through the same `basket_totals` the validate endpoint uses, so the
|
||||
# preview a buyer saw is what gets charged. Unlike validate, a bad code
|
||||
# is a hard error here — silently charging full price would be worse.
|
||||
if promo_code:
|
||||
# check if promo_code exists in event.extra.promo_codes
|
||||
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
|
||||
promo = find_promo(event, promo_code)
|
||||
if not promo:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
|
||||
)
|
||||
# get the promocode
|
||||
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
|
||||
if not promo.active:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Promo code is not active.",
|
||||
)
|
||||
usage = await event_promo_usage(event.id)
|
||||
uses_left = remaining_uses(promo, usage.get(promo.code, 0))
|
||||
if uses_left is not None and uses_left < quantity:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
"Promo code has been fully redeemed."
|
||||
if uses_left == 0
|
||||
else f"Only {uses_left} use(s) left on this promo code."
|
||||
),
|
||||
)
|
||||
extra["promo_code"] = promo.code
|
||||
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
|
||||
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
|
||||
price = unit_price * quantity
|
||||
price = basket_totals(event, [promo.code], quantity, usage).total
|
||||
else:
|
||||
price = round_amount(event.price_per_ticket * quantity, event.currency)
|
||||
|
||||
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
||||
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
||||
|
|
@ -784,6 +826,7 @@ async def api_ticket_create(
|
|||
"event_id": event.id,
|
||||
"quantity": str(quantity),
|
||||
"ticket_ids": ",".join(ticket_ids),
|
||||
**({"promo_code": promo_code} if promo_code else {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1042,6 +1085,7 @@ async def api_event_ticket_stats(
|
|||
"registered_at": (
|
||||
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
||||
),
|
||||
"applied_promo_code": t.extra.applied_promo_code,
|
||||
}
|
||||
for t in paid_tickets
|
||||
],
|
||||
|
|
@ -1100,3 +1144,21 @@ async def api_ticket_card(ticket_id: str):
|
|||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@promo_api_router.post("/validate/{event_id}")
|
||||
async def api_validate_promo_codes(
|
||||
event_id: str, data: PromoValidateRequest
|
||||
) -> BasketTotals:
|
||||
"""Price a purchase with the given codes without committing to it —
|
||||
what the buyer sees before paying. Anonymous and advisory: a code that
|
||||
is unknown / inactive / exhausted is simply absent from
|
||||
`discounts_applied`; the purchase endpoint is where hard errors live.
|
||||
Same URL as upstream v2 (`quantity` instead of v2's `items`)."""
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||
)
|
||||
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
|
||||
return basket_totals(event, data.codes, data.quantity, usage)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue