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
175
tests/test_promo.py
Normal file
175
tests/test_promo.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..models import Event, EventExtra, PromoCode, PublicEvent, Ticket
|
||||
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
|
||||
|
||||
|
||||
def _event(currency="sat", price=1000.0, codes=None) -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Test",
|
||||
info="",
|
||||
closing_date="2030-01-01",
|
||||
event_start_date="2030-01-01",
|
||||
event_end_date="2030-01-02",
|
||||
currency=currency,
|
||||
price_per_ticket=price,
|
||||
amount_tickets=10,
|
||||
time=datetime.now(timezone.utc),
|
||||
extra=EventExtra(promo_codes=codes or []),
|
||||
)
|
||||
|
||||
|
||||
def _ticket(code, paid=True) -> Ticket:
|
||||
now = datetime.now(timezone.utc)
|
||||
return Ticket(
|
||||
id=f"t-{code}-{paid}",
|
||||
wallet="w",
|
||||
event="evt",
|
||||
registered=False,
|
||||
paid=paid,
|
||||
time=now,
|
||||
reg_timestamp=now,
|
||||
extra={"applied_promo_code": code},
|
||||
)
|
||||
|
||||
|
||||
# --- model -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_code_is_stripped_and_uppercased():
|
||||
assert PromoCode(code=" half ", discount_percent=50).code == "HALF"
|
||||
|
||||
|
||||
def test_empty_code_is_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
PromoCode(code=" ", discount_percent=10)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)]
|
||||
)
|
||||
def test_max_uses_normalisation(raw, expected):
|
||||
assert PromoCode(code="X", max_uses=raw).max_uses == expected
|
||||
|
||||
|
||||
def test_max_uses_below_one_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
PromoCode(code="X", max_uses=-1)
|
||||
|
||||
|
||||
def test_discount_bounds():
|
||||
with pytest.raises(ValidationError):
|
||||
PromoCode(code="X", discount_percent=101)
|
||||
|
||||
|
||||
def test_public_event_projection_drops_promo_codes():
|
||||
event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)])
|
||||
public = PublicEvent.parse_obj(event.dict()).dict()
|
||||
assert "promo_codes" not in public["extra"]
|
||||
assert public["extra"]["payment_methods"] == []
|
||||
# the full model keeps them
|
||||
assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET"
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_code():
|
||||
assert normalize_code(" save20 ") == "SAVE20"
|
||||
assert normalize_code("") is None
|
||||
assert normalize_code(None) is None
|
||||
|
||||
|
||||
def test_promo_usage_counts_paid_rows_only_per_ticket():
|
||||
usage = promo_usage(
|
||||
[_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)]
|
||||
)
|
||||
assert usage == {"HALF": 2}
|
||||
|
||||
|
||||
def test_remaining_uses():
|
||||
assert remaining_uses(PromoCode(code="X"), 5) is None
|
||||
assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2
|
||||
assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0
|
||||
|
||||
|
||||
# --- basket_totals -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_sat_totals_round_to_whole_sats():
|
||||
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
||||
totals = basket_totals(event, ["off15"], 1, {})
|
||||
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
||||
assert totals.currency == "sat"
|
||||
assert totals.discounts_applied[0].dict() == {
|
||||
"code": "OFF15",
|
||||
"discount_percent": 15,
|
||||
"discount_fixed": None,
|
||||
"amount_saved": 50,
|
||||
}
|
||||
|
||||
|
||||
def test_fiat_totals_round_to_cents_and_scale_by_quantity():
|
||||
event = _event(
|
||||
currency="EUR",
|
||||
price=19.99,
|
||||
codes=[PromoCode(code="THIRD", discount_percent=33)],
|
||||
)
|
||||
totals = basket_totals(event, ["THIRD"], 3, {})
|
||||
assert totals.subtotal == 59.97
|
||||
assert totals.total == 40.18
|
||||
assert totals.discount == 19.79
|
||||
assert totals.discount + totals.total == totals.subtotal
|
||||
|
||||
|
||||
def test_first_applicable_code_wins():
|
||||
event = _event(
|
||||
codes=[
|
||||
PromoCode(code="A", discount_percent=10),
|
||||
PromoCode(code="B", discount_percent=50),
|
||||
]
|
||||
)
|
||||
assert (
|
||||
basket_totals(event, ["NOPE", "B", "A"], 1, {}).discounts_applied[0].code == "B"
|
||||
)
|
||||
|
||||
|
||||
def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
|
||||
event = _event(
|
||||
codes=[
|
||||
PromoCode(code="OLD", discount_percent=20, active=False),
|
||||
PromoCode(code="ZERO", discount_percent=0),
|
||||
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
||||
]
|
||||
)
|
||||
for codes, usage, qty in (
|
||||
(["OLD"], {}, 1),
|
||||
(["ZERO"], {}, 1),
|
||||
(["NOPE"], {}, 1),
|
||||
(["TWO"], {"TWO": 2}, 1),
|
||||
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
|
||||
):
|
||||
totals = basket_totals(event, codes, qty, usage)
|
||||
assert totals.discounts_applied == []
|
||||
assert totals.total == totals.subtotal and totals.discount == 0
|
||||
|
||||
|
||||
def test_unlimited_and_partially_used_codes_apply():
|
||||
event = _event(
|
||||
codes=[
|
||||
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
||||
PromoCode(code="INF", discount_percent=10),
|
||||
]
|
||||
)
|
||||
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}).total == 500
|
||||
assert basket_totals(event, ["INF"], 10, {"INF": 999}).total == 9000
|
||||
|
||||
|
||||
def test_full_discount_prices_to_zero():
|
||||
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
||||
assert basket_totals(event, ["FREE"], 2, {}).total == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue