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
108
promo.py
Normal file
108
promo.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Promo-code arithmetic shared by the validate endpoint and the purchase path.
|
||||
|
||||
Pure functions (no DB, no settings) so the number a buyer sees in the
|
||||
"Apply" preview is exactly the number the invoice / Stripe session charges.
|
||||
Field names and the `BasketTotals` shape follow upstream lnbits/events v2
|
||||
(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted
|
||||
inline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket
|
||||
|
||||
SAT_UNITS = ("sat", "sats")
|
||||
|
||||
|
||||
def normalize_code(raw: str | None) -> str | None:
|
||||
"""Buyer input → stored form (stripped, upper-cased); empty → None."""
|
||||
if raw is None:
|
||||
return None
|
||||
code = raw.strip().upper()
|
||||
return code or None
|
||||
|
||||
|
||||
def find_promo(event: Event, code: str) -> PromoCode | None:
|
||||
return next((pc for pc in event.extra.promo_codes if pc.code == code), None)
|
||||
|
||||
|
||||
def promo_usage(tickets: list[Ticket]) -> dict[str, int]:
|
||||
"""Redemptions per code = PAID tickets carrying it in
|
||||
`extra.applied_promo_code`. Every row counts, so a multi-ticket
|
||||
purchase consumes `quantity` uses (upstream v2 counts one per basket).
|
||||
|
||||
Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so
|
||||
counting them would let an abandoned Stripe session lock out the last
|
||||
uses of a limited code for a day. The cost is a bounded overshoot when
|
||||
several buyers pass the check before any of them pays — accepted.
|
||||
"""
|
||||
counter: Counter[str] = Counter()
|
||||
for ticket in tickets:
|
||||
code = ticket.extra.applied_promo_code
|
||||
if ticket.paid and code:
|
||||
counter[code] += 1
|
||||
return dict(counter)
|
||||
|
||||
|
||||
def remaining_uses(promo: PromoCode, used: int) -> int | None:
|
||||
"""None = unlimited (`max_uses` unset / 0)."""
|
||||
if not promo.max_uses:
|
||||
return None
|
||||
return max(promo.max_uses - used, 0)
|
||||
|
||||
|
||||
def round_amount(amount: float, currency: str | None) -> float:
|
||||
"""Sats are integers; fiat is 2 dp. Applied once, at the end, so
|
||||
subtotal - total == discount holds for what is actually charged."""
|
||||
if (currency or "sat").lower() in SAT_UNITS:
|
||||
return float(int(amount))
|
||||
return round(amount, 2)
|
||||
|
||||
|
||||
def basket_totals(
|
||||
event: Event,
|
||||
codes: list[str],
|
||||
quantity: int,
|
||||
usage: dict[str, int],
|
||||
) -> BasketTotals:
|
||||
"""Price `quantity` tickets with the first applicable code in `codes`.
|
||||
|
||||
A code is applicable when it exists, is active, has enough uses left
|
||||
for the whole quantity, and actually saves something. Anything else is
|
||||
simply absent from `discounts_applied` (upstream v2 semantics — the
|
||||
purchase endpoint is where hard errors are raised). Only one code is
|
||||
applied; v2's `combinable` stacking is out of scope here.
|
||||
"""
|
||||
currency = event.currency or "sat"
|
||||
subtotal = round_amount(event.price_per_ticket * quantity, currency)
|
||||
totals = BasketTotals(
|
||||
subtotal=subtotal, discount=0, total=subtotal, currency=currency
|
||||
)
|
||||
for raw in codes:
|
||||
code = normalize_code(raw)
|
||||
if not code:
|
||||
continue
|
||||
promo = find_promo(event, code)
|
||||
if not promo or not promo.active:
|
||||
continue
|
||||
remaining = remaining_uses(promo, usage.get(promo.code, 0))
|
||||
if remaining is not None and remaining < quantity:
|
||||
continue
|
||||
total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency)
|
||||
saved = round_amount(subtotal - total, currency)
|
||||
if saved <= 0:
|
||||
continue
|
||||
totals.total = total
|
||||
totals.discount = saved
|
||||
totals.discounts_applied = [
|
||||
BasketDiscount(
|
||||
code=promo.code,
|
||||
discount_percent=promo.discount_percent,
|
||||
discount_fixed=None,
|
||||
amount_saved=saved,
|
||||
)
|
||||
]
|
||||
break
|
||||
return totals
|
||||
Loading…
Add table
Add a link
Reference in a new issue