amount_tickets is remaining in one place and capacity in another — event goes "sold out" at half capacity #53

Closed
opened 2026-09-26 12:43:01 +00:00 by padreug · 3 comments
Owner

Two parts of the extension disagree about what amount_tickets means.

set_ticket_paid (services.py:72-73) treats it as remaining:

event.sold += 1
event.amount_tickets -= 1

api_ticket_create (views_api.py:673-682) treats it as original capacity:

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:
    raise HTTPException(..., detail=f"Only {remaining} ticket(s) remaining for this event.")

The decrement wins — views_api.py:287 (if event.amount_tickets < 1: sold out) and the NIP-52 tickets_available tag both read it as remaining, and the admin table labels it "No tickets" next to a separate "Sold" column.

So on a 50-ticket event, after n sales amount_tickets = 50 - n and sold = n, and the purchase endpoint computes remaining = 50 - 2n:

  • it under-reports remaining by n and will refuse orders it could fill
  • sold >= amount_tickets becomes true at n = 25, so the event declares itself sold out with 25 tickets still available

Live on cfaun right now: Q6mhQQzQPSXqkQuFndzXnf has amount_tickets=45, sold=5, so an order larger than 40 is already refused, and it will go sold-out at 25.

Fix

Pick one meaning and make everything agree. amount_tickets-as-remaining is what the schema, the published tag and the admin UI already assume, so the smaller change is in api_ticket_create:

if event.amount_tickets > 0:
    if event.amount_tickets < 1:   # or just: <= 0
        raise ... "Event is sold out."
    remaining = event.amount_tickets
    if quantity > remaining:
        raise ... f"Only {remaining} ticket(s) remaining for this event."

Worth a test that walks an event to full capacity and asserts the last ticket still sells. Check the other sold/amount_tickets comparisons at the same time — extra.min_tickets in the conditional-event path reads sold and is fine, but the pair should be audited together.

Two parts of the extension disagree about what `amount_tickets` means. `set_ticket_paid` (`services.py:72-73`) treats it as **remaining**: ```python event.sold += 1 event.amount_tickets -= 1 ``` `api_ticket_create` (`views_api.py:673-682`) treats it as **original capacity**: ```python 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: raise HTTPException(..., detail=f"Only {remaining} ticket(s) remaining for this event.") ``` The decrement wins — `views_api.py:287` (`if event.amount_tickets < 1: sold out`) and the NIP-52 `tickets_available` tag both read it as remaining, and the admin table labels it "No tickets" next to a separate "Sold" column. So on a 50-ticket event, after n sales `amount_tickets = 50 - n` and `sold = n`, and the purchase endpoint computes `remaining = 50 - 2n`: - it under-reports remaining by `n` and will refuse orders it could fill - `sold >= amount_tickets` becomes true at `n = 25`, so the event declares itself **sold out with 25 tickets still available** Live on cfaun right now: `Q6mhQQzQPSXqkQuFndzXnf` has `amount_tickets=45, sold=5`, so an order larger than 40 is already refused, and it will go sold-out at 25. ## Fix Pick one meaning and make everything agree. `amount_tickets`-as-remaining is what the schema, the published tag and the admin UI already assume, so the smaller change is in `api_ticket_create`: ```python if event.amount_tickets > 0: if event.amount_tickets < 1: # or just: <= 0 raise ... "Event is sold out." remaining = event.amount_tickets if quantity > remaining: raise ... f"Only {remaining} ticket(s) remaining for this event." ``` Worth a test that walks an event to full capacity and asserts the last ticket still sells. Check the other `sold`/`amount_tickets` comparisons at the same time — `extra.min_tickets` in the conditional-event path reads `sold` and is fine, but the pair should be audited together.
Author
Owner

Upstream review before touching this

Checked lnbits/events (upstream/main @ 891eaf1, tags through v1.6.8; our merge-base is v1.6.1). Upstream is completely consistent: amount_tickets is remaining, everywhere, with no exceptions.

services.py:95-105:

event.sold += 1
if selected_wave:
    if selected_wave.amount_tickets > 0:
        selected_wave.amount_tickets -= 1
elif event.amount_tickets > 0:
    event.amount_tickets -= 1

and every capacity check is the same one line — views_api.py:188 and :415:

if event.amount_tickets < 1:
    raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")

event.sold is never subtracted from amount_tickets anywhere upstream. It's used only for extra.min_tickets on conditional events and for display.

There's no remaining = amount_tickets - sold upstream because there's nothing to compute it for: upstream's CreateTicket has no quantity — one ticket per request. The bug is entirely ours, introduced with the multi-quantity purchase feature. So the fix is to delete the aio-introduced arithmetic, not to reconcile two competing conventions:

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 {event.amount_tickets} ticket(s) remaining for this event.",
    )

That's upstream's guard plus the one line quantity actually needs, which should rebase cleanly.

Relevant for the rebase

Upstream v1.6.x added ticket waves (TicketWave, per-wave amount_tickets + price_per_ticket, get_active_ticket_waves), and the event-level field became a derived roll-up — models.py:240:

event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)

Still remaining-semantics, just aggregated. Our capacity logic will need to move to the selected wave when we rebase onto v1.6.8, so keeping this fix minimal and upstream-shaped matters more than usual. Worth a line in the "Rebase onto v1.6.8" issue.

Second, separate collision found while checking

Our fork has a third reading of the field that upstream doesn't share — 0 = unlimited:

  • models.py:96 — amount_tickets: int = 0 # 0 = unlimited / not ticketed (upstream has it required: Query(..., ge=0))
  • nostr_publisher.py:99-103 — omits the tickets_available tag when amount_tickets == 0, with the comment "Omitting the tag is how clients distinguish unlimited from '0 left' (sold out)"
  • webapp event.ts:100 derives total only when available !== undefined, and EventDetailPage.vue:416 renders "Unlimited tickets" for available === undefined

That collides head-on with views_api.py:287 in api_get_event (the public event detail endpoint), which is upstream's if event.amount_tickets < 1: sold out. So an event created with amount_tickets = 0 advertises "Unlimited tickets" on oyez while the public detail endpoint returns 410 GONE and every purchase is refused as sold out.

Needs deciding before the fix above lands, since both touch the same predicate: either drop the unlimited concept and follow upstream (0 = sold out, capacity always required), or keep it and make the sold-out checks amount_tickets == 0 → unlimited aware — which is a deliberate deviation to record in docs/upstream-candidates.md and the rebase issue. Upstream's waves make the first option the cheaper one long-term.

## Upstream review before touching this Checked `lnbits/events` (`upstream/main` @ `891eaf1`, tags through v1.6.8; our merge-base is v1.6.1). Upstream is completely consistent: **`amount_tickets` is remaining, everywhere, with no exceptions.** `services.py:95-105`: ```python event.sold += 1 if selected_wave: if selected_wave.amount_tickets > 0: selected_wave.amount_tickets -= 1 elif event.amount_tickets > 0: event.amount_tickets -= 1 ``` and every capacity check is the same one line — `views_api.py:188` and `:415`: ```python if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") ``` `event.sold` is never subtracted from `amount_tickets` anywhere upstream. It's used only for `extra.min_tickets` on conditional events and for display. There's no `remaining = amount_tickets - sold` upstream because there's nothing to compute it for: upstream's `CreateTicket` has no `quantity` — one ticket per request. **The bug is entirely ours, introduced with the multi-quantity purchase feature.** So the fix is to delete the aio-introduced arithmetic, not to reconcile two competing conventions: ```python 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 {event.amount_tickets} ticket(s) remaining for this event.", ) ``` That's upstream's guard plus the one line `quantity` actually needs, which should rebase cleanly. ## Relevant for the rebase Upstream v1.6.x added **ticket waves** (`TicketWave`, per-wave `amount_tickets` + `price_per_ticket`, `get_active_ticket_waves`), and the event-level field became a derived roll-up — `models.py:240`: ```python event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves) ``` Still remaining-semantics, just aggregated. Our capacity logic will need to move to the selected wave when we rebase onto v1.6.8, so keeping this fix minimal and upstream-shaped matters more than usual. Worth a line in the "Rebase onto v1.6.8" issue. ## Second, separate collision found while checking Our fork has a *third* reading of the field that upstream doesn't share — `0 = unlimited`: - `models.py:96` — `amount_tickets: int = 0 # 0 = unlimited / not ticketed` (upstream has it required: `Query(..., ge=0)`) - `nostr_publisher.py:99-103` — omits the `tickets_available` tag when `amount_tickets == 0`, with the comment "Omitting the tag is how clients distinguish unlimited from '0 left' (sold out)" - webapp `event.ts:100` derives `total` only when `available !== undefined`, and `EventDetailPage.vue:416` renders "Unlimited tickets" for `available === undefined` That collides head-on with `views_api.py:287` in `api_get_event` (the public event detail endpoint), which is upstream's `if event.amount_tickets < 1: sold out`. So an event created with `amount_tickets = 0` advertises "Unlimited tickets" on oyez while the public detail endpoint returns 410 GONE and every purchase is refused as sold out. Needs deciding before the fix above lands, since both touch the same predicate: either drop the unlimited concept and follow upstream (`0` = sold out, capacity always required), or keep it and make the sold-out checks `amount_tickets == 0 → unlimited` aware — which is a deliberate deviation to record in `docs/upstream-candidates.md` and the rebase issue. Upstream's waves make the first option the cheaper one long-term.
Author
Owner

Decision: follow upstream, capacity always required

Dropping the 0 = unlimited reading. amount_tickets means remaining, full stop, matching upstream — which also means we don't fight the wave roll-up at rebase time.

Free tickets are unaffected. They're gated on the computed charge, not on capacity (views_api.py:743):

price = round_amount(event.price_per_ticket * quantity, event.currency)
...
if price <= 0:
    return await _issue_free_tickets(...)

A free event stays price_per_ticket = 0 with a real amount_tickets. The only thing lost is an uncapped event; large number if you want effectively-unlimited.

Scope

  • views_api.py:673-682 — delete the aio-introduced remaining = amount_tickets - sold arithmetic; upstream's if event.amount_tickets < 1 guard plus if quantity > event.amount_tickets.
  • models.py:96 — CreateEvent.amount_tickets required instead of defaulting to 0, and drop the 0 = unlimited / not ticketed comment. Upstream has it Query(..., ge=0); ge=1 on create is a small deliberate deviation that stops an event being born dead — worth a line in docs/upstream-candidates.md either way.
  • nostr_publisher.py:99-103 — always emit tickets_available; remove the omit-when-zero branch and its comment about clients distinguishing unlimited from sold out.
  • Admin UI event form — capacity becomes a required field.
  • Test walking an event to full capacity, asserting the last ticket still sells and the one after is refused.

Not changing the webapp. EventDetailPage.vue:416 / event.ts:100 render "Unlimited tickets" when tickets_available is absent, which becomes unreachable for our own events but stays correct for a NIP-52 event published by any third-party client — keep it as the foreign-event fallback, consistent with the client-agnostic target.

Pre-deploy check

Any existing event rows with amount_tickets = 0 need a decision — backfill to a real capacity, or leave them reading as sold out. They're already broken today (api_get_event 410s on them), but the migration shouldn't silently pick for the organizer. Check each castle host before this ships.

## Decision: follow upstream, capacity always required Dropping the `0 = unlimited` reading. `amount_tickets` means remaining, full stop, matching upstream — which also means we don't fight the wave roll-up at rebase time. **Free tickets are unaffected.** They're gated on the computed charge, not on capacity (`views_api.py:743`): ```python price = round_amount(event.price_per_ticket * quantity, event.currency) ... if price <= 0: return await _issue_free_tickets(...) ``` A free event stays `price_per_ticket = 0` with a real `amount_tickets`. The only thing lost is an *uncapped* event; large number if you want effectively-unlimited. ## Scope - `views_api.py:673-682` — delete the aio-introduced `remaining = amount_tickets - sold` arithmetic; upstream's `if event.amount_tickets < 1` guard plus `if quantity > event.amount_tickets`. - `models.py:96` — `CreateEvent.amount_tickets` required instead of defaulting to 0, and drop the `0 = unlimited / not ticketed` comment. Upstream has it `Query(..., ge=0)`; `ge=1` on create is a small deliberate deviation that stops an event being born dead — worth a line in `docs/upstream-candidates.md` either way. - `nostr_publisher.py:99-103` — always emit `tickets_available`; remove the omit-when-zero branch and its comment about clients distinguishing unlimited from sold out. - Admin UI event form — capacity becomes a required field. - Test walking an event to full capacity, asserting the last ticket still sells and the one after is refused. **Not** changing the webapp. `EventDetailPage.vue:416` / `event.ts:100` render "Unlimited tickets" when `tickets_available` is absent, which becomes unreachable for our own events but stays correct for a NIP-52 event published by any third-party client — keep it as the foreign-event fallback, consistent with the client-agnostic target. ## Pre-deploy check Any existing event rows with `amount_tickets = 0` need a decision — backfill to a real capacity, or leave them reading as sold out. They're already broken today (`api_get_event` 410s on them), but the migration shouldn't silently pick for the organizer. Check each castle host before this ships.
Author
Owner

Duplicate of #34, which diagnosed the same conflation in September with better evidence — it also found the part I missed, that the 10 of 17 inflation comes from the webapp reconstructing a total that was never stored, and that the organizer form's prefill is what drives an organizer into setting capacity wrong. Filed this before checking the tracker — my mistake.

The upstream review and the 0 = unlimited collision are reproduced as a comment on #34, along with the argument that upstream's wave roll-up makes the capacity column proposed there the more expensive option.

Closing in favour of #34.

Duplicate of #34, which diagnosed the same conflation in September with better evidence — it also found the part I missed, that the `10 of 17` inflation comes from the webapp reconstructing a total that was never stored, and that the organizer form's prefill is what drives an organizer into setting capacity wrong. Filed this before checking the tracker — my mistake. The upstream review and the `0 = unlimited` collision are reproduced as a comment on #34, along with the argument that upstream's wave roll-up makes the `capacity` column proposed there the more expensive option. Closing in favour of #34.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/events#53
No description provided.