fix(tickets): amount_tickets is the remaining count, stop subtracting sold #59

Merged
padreug merged 1 commit from fix/amount-tickets-remaining into main 2026-09-27 20:27:10 +00:00
2 changed files with 124 additions and 11 deletions
Showing only changes of commit ad8aa2cf00 - Show all commits

fix(tickets): amount_tickets is the remaining count, stop subtracting sold
Some checks failed
lint.yml / fix(tickets): amount_tickets is the remaining count, stop subtracting sold (pull_request) Failing after 0s

`set_ticket_paid` decrements `amount_tickets` on every sale and
increments `sold`, so the two describe the same tickets. Subtracting
one from the other in `api_ticket_create` removed each sale twice:

  remaining = amount_tickets - sold

That under-reported availability to buyers, and because
`remaining + sold` is the original capacity, `sold >= amount_tickets`
first becomes true at the halfway point — so every event locked itself
as sold out once half its seats had gone, with the rest still unsold.

Measured on aio-demo before the fix: 16 of 24 live events affected,
3 of them already refusing sales while stock remained. "Tech Meetup"
had 9 tickets left and offered -2.

The arithmetic was ours, introduced with the multi-quantity purchase
feature; upstream has no equivalent because it sells one ticket per
request and reads `amount_tickets` as remaining everywhere. So this
deletes the divergence and restores upstream's own guard plus the one
line `quantity` needs, which should also make the v1.6.8 rebase (#33)
a little easier rather than harder.

Tests walk a 50-seat event to capacity one sale at a time and pin the
boundary: sold=49 passes even unfixed, sold=50 is where it used to
lock. Six of the ten fail without the change.

Scoped deliberately to the double-subtraction. The rest of #34 — making
capacity a required field, dropping the `0 = unlimited` reading, and the
organizer form that prefills remaining as though it were capacity —
waits for #33, since ticket waves change the shape of that fix.

Refs #34
Padreug 2026-09-27 22:25:01 +02:00

View file

@ -0,0 +1,112 @@
"""`amount_tickets` is the remaining count (aiolabs/events#34).
`set_ticket_paid` decrements it on every sale and `sold` increments, so
subtracting `sold` from it removes each sale twice. That made the buyer
cap under-report and, once an event passed half its capacity, turned
`sold >= amount_tickets` true and declared it sold out with stock left.
Measured on aio-demo before the fix: 16 of 24 live events affected,
3 already refusing sales while tickets remained.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from .. import views_api
from ..models import CreateTicket, Event
SENTINEL = object()
def _event(amount_tickets: int, sold: int) -> Event:
return Event(
id="evt",
wallet="w",
name="Capacity",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01",
event_end_date="2030-01-02",
currency="sat",
price_per_ticket=0, # free path, so the guard is all that gates us
amount_tickets=amount_tickets,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
)
@pytest.fixture
def issued(monkeypatch):
"""Past the capacity guard the free path short-circuits to a sentinel."""
monkeypatch.setattr(
views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL)
)
monkeypatch.setattr(
views_api, "_resolve_frontend_root", lambda data, req: "http://x"
)
return SENTINEL
async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch):
monkeypatch.setattr(
views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold))
)
return await views_api.api_ticket_create(
"evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace()
)
@pytest.mark.asyncio
async def test_last_ticket_still_sells(monkeypatch, issued):
"""One left, one wanted. Previously refused once sold >= remaining."""
assert await _buy(1, 99, 1, monkeypatch) is issued
@pytest.mark.asyncio
async def test_sold_out_only_when_actually_empty(monkeypatch, issued):
with pytest.raises(HTTPException) as exc:
await _buy(0, 100, 1, monkeypatch)
assert exc.value.detail == "Event is sold out."
@pytest.mark.asyncio
@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500])
async def test_remaining_alone_decides_availability(monkeypatch, issued, sold):
"""The regression proper: with 50 left the event sells, whatever
`sold` says. Because remaining + sold is the original capacity,
`sold >= amount_tickets` first flips true at the halfway point —
sold=50 here — so an event locked itself once half its seats went.
The boundary cases (49/50/51) are the ones that matter."""
assert await _buy(50, sold, 1, monkeypatch) is issued
@pytest.mark.asyncio
async def test_bulk_order_may_take_everything_left(monkeypatch, issued):
assert await _buy(10, 40, 10, monkeypatch) is issued
@pytest.mark.asyncio
async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued):
"""3 left, 5 wanted. The message must name the real remainder — it
used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10
by the model, so the overshoot is tested within that bound.)"""
with pytest.raises(HTTPException) as exc:
await _buy(3, 40, 5, monkeypatch)
assert exc.value.detail == "Only 3 ticket(s) remaining for this event."
@pytest.mark.asyncio
async def test_walk_an_event_to_capacity(monkeypatch, issued):
"""50-seat event, one sale at a time, mirroring set_ticket_paid."""
remaining, sold = 50, 0
for _ in range(50):
assert await _buy(remaining, sold, 1, monkeypatch) is issued
remaining, sold = remaining - 1, sold + 1
assert (remaining, sold) == (0, 50)
with pytest.raises(HTTPException) as exc:
await _buy(remaining, sold, 1, monkeypatch)
assert exc.value.detail == "Event is sold out."

View file

@ -670,16 +670,17 @@ async def api_ticket_create(
if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
quantity = data.quantity
if event.amount_tickets > 0:
if event.sold >= event.amount_tickets:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="Event is sold out."
)
remaining = event.amount_tickets - event.sold
if quantity > remaining:
# `amount_tickets` IS the remaining count — `set_ticket_paid` decrements
# it on every sale, and upstream reads it the same way everywhere. Do
# not subtract `sold` from it: `sold` counts the same tickets the
# decrement already removed, so doing both takes each sale off twice
# (aiolabs/events#34).
if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if quantity > event.amount_tickets:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Only {remaining} ticket(s) remaining for this event.",
detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.",
)
name = data.name