amount_tickets conflates "remaining" with "total capacity" — inflated ticket totals + double-subtracted buyer cap #34

Open
opened 2026-09-06 09:38:42 +00:00 by padreug · 3 comments
Owner

Symptom

On aio-demo, the "Transhumance of the Merens" event card renders an
impossible total. The organizer set the event to 10 tickets; the card
shows "10 of 17 tickets left".

There is no value the organizer can type into the current form that
makes the card read X of 10, short of hand-computing 10 - sold.

Root cause

amount_tickets is used with two incompatible meanings.

As remaining capacity — services.py:59-60, on every sale
(upstream semantics, predates the aio fork):

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

As total capacity — views_api.py:589-594, introduced by the
fork's multi-ticket commit 59068fe:

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

Total capacity is never stored. nostr_publisher.py:103 publishes
tickets_available = amount_tickets (correct under remaining-semantics),
and the webapp reconstructs the total by addition
(src/modules/events/types/event.ts:99):

total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined

That derivation only holds while the event is never edited. Each edit
re-baselines remaining, so the apparent total jumps by sold.

The organizer form reinforces the wrong reading: the field is labeled
just "Tickets" with helper text "0 = unlimited"
(webapp CreateEventDialog.vue:621), and the edit form prefills it
with the raw remaining value (:248). An organizer with 7 sold sees
9, reads it as "capacity 9", corrects it to 10, and silently sets
capacity to 17.

Evidence

Event hyhNjddtsZ7JNbezwX8ERB on aio-demo, DB state after the
organizer set the field to 10:

amount_tickets: 10   sold: 7   (7 paid ticket rows)
=> webapp total: 17

Published NIP-52 tags at that point:

['tickets_available', '10']
['tickets_sold', '7']

Drift history for this one row — amount_tickets + sold should be
invariant across sales, and is not:

when amount_tickets sold implied total
2026-08-09 (published) 7 4 11
2026-09-05 (before edit) 9 7 16
2026-09-06 (after edit to "10") 10 7 17

The original capacity for this event is no longer recoverable from the
data.

Consequences

  1. Inflated totals on the event card, growing by sold on every edit.
  2. Buyer quantity cap is double-subtracted. views_api.py:594
    computes remaining = 10 - 7 = 3 when the backend actually believes
    10 are free, so orders above 3 are rejected.
  3. False sold-out. views_api.py:590 raises 410 GONE once
    sold >= amount_tickets, which is unrelated to real availability.
  4. Organizers cannot see or set true capacity anywhere in the UI.

Proposed fix

  • Add a capacity column via migrations_fork.py (idempotent, per the
    fork-migrations pattern); backfill capacity = amount_tickets + sold.
  • Stop decrementing amount_tickets; derive remaining = capacity - sold.
  • Publish tickets_available = capacity - sold and add an explicit
    tickets_total tag so clients stop inferring the total by addition.
  • Fix the views_api.py:589-594 check to compare against capacity.
  • Webapp: read tickets_total directly; keep the available + sold
    derivation only as a fallback for events published before the change.
  • Split the organizer form into distinct capacity / remaining fields, or
    at minimum relabel the existing one honestly.

Open question: the backfill lands already-edited rows at their inflated
value (this event → 17). Those need a manual correction pass after
migration; there is no way to recover intent from the data.

nostr_hooks.py:46 swallows publish failures at warning level with no
retry. On 2026-09-05 an nsecbunkerd signing outage
(no NIP-46 response for 'sign_event' within 15.0s) silently dropped
every republish for three consecutive sales, leaving the relay serving a
month-old copy of this event with no operator signal. nsecbunkerd has
since been fixed, but the swallow-and-forget path remains. Worth its own
issue.

## Symptom On aio-demo, the "Transhumance of the Merens" event card renders an impossible total. The organizer set the event to 10 tickets; the card shows **"10 of 17 tickets left"**. There is no value the organizer can type into the current form that makes the card read `X of 10`, short of hand-computing `10 - sold`. ## Root cause `amount_tickets` is used with two incompatible meanings. **As remaining capacity** — `services.py:59-60`, on every sale (upstream semantics, predates the aio fork): ```python event.sold += 1 event.amount_tickets -= 1 ``` **As total capacity** — `views_api.py:589-594`, introduced by the fork's multi-ticket commit `59068fe`: ```python 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: raise HTTPException(..., detail=f"Only {remaining} ticket(s) remaining for this event.") ``` **Total capacity is never stored.** `nostr_publisher.py:103` publishes `tickets_available = amount_tickets` (correct under remaining-semantics), and the webapp reconstructs the total by addition (`src/modules/events/types/event.ts:99`): ```ts total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined ``` That derivation only holds while the event is never edited. Each edit re-baselines *remaining*, so the apparent total jumps by `sold`. The organizer form reinforces the wrong reading: the field is labeled just **"Tickets"** with helper text "0 = unlimited" (`webapp` `CreateEventDialog.vue:621`), and the edit form prefills it with the raw *remaining* value (`:248`). An organizer with 7 sold sees `9`, reads it as "capacity 9", corrects it to `10`, and silently sets capacity to 17. ## Evidence Event `hyhNjddtsZ7JNbezwX8ERB` on aio-demo, DB state after the organizer set the field to 10: ``` amount_tickets: 10 sold: 7 (7 paid ticket rows) => webapp total: 17 ``` Published NIP-52 tags at that point: ``` ['tickets_available', '10'] ['tickets_sold', '7'] ``` Drift history for this one row — `amount_tickets + sold` should be invariant across sales, and is not: | when | amount_tickets | sold | implied total | |---|---|---|---| | 2026-08-09 (published) | 7 | 4 | 11 | | 2026-09-05 (before edit) | 9 | 7 | 16 | | 2026-09-06 (after edit to "10") | 10 | 7 | 17 | The original capacity for this event is no longer recoverable from the data. ## Consequences 1. **Inflated totals on the event card**, growing by `sold` on every edit. 2. **Buyer quantity cap is double-subtracted.** `views_api.py:594` computes `remaining = 10 - 7 = 3` when the backend actually believes 10 are free, so orders above 3 are rejected. 3. **False sold-out.** `views_api.py:590` raises `410 GONE` once `sold >= amount_tickets`, which is unrelated to real availability. 4. Organizers cannot see or set true capacity anywhere in the UI. ## Proposed fix - Add a `capacity` column via `migrations_fork.py` (idempotent, per the fork-migrations pattern); backfill `capacity = amount_tickets + sold`. - Stop decrementing `amount_tickets`; derive `remaining = capacity - sold`. - Publish `tickets_available = capacity - sold` and add an explicit `tickets_total` tag so clients stop inferring the total by addition. - Fix the `views_api.py:589-594` check to compare against `capacity`. - Webapp: read `tickets_total` directly; keep the `available + sold` derivation only as a fallback for events published before the change. - Split the organizer form into distinct capacity / remaining fields, or at minimum relabel the existing one honestly. Open question: the backfill lands already-edited rows at their inflated value (this event → 17). Those need a manual correction pass after migration; there is no way to recover intent from the data. ## Related, not covered here `nostr_hooks.py:46` swallows publish failures at `warning` level with no retry. On 2026-09-05 an nsecbunkerd signing outage (`no NIP-46 response for 'sign_event' within 15.0s`) silently dropped every republish for three consecutive sales, leaving the relay serving a month-old copy of this event with no operator signal. nsecbunkerd has since been fixed, but the swallow-and-forget path remains. Worth its own issue.
Author
Owner

Design note: keep the "remaining" input, store capacity

Supersedes the "split the organizer form into distinct capacity /
remaining fields" bullet in the proposed fix above.

The case for the current remaining-as-truth model

There is a real property worth preserving in the upstream model. Under
capacity-as-truth, "set capacity to 40 when 85 are already sold" is a
representable state, so something has to reject it. Under
remaining-as-truth it is simply unsayable — every value in [0, ∞) is
valid at every moment, and 0 cleanly means "stop selling" with no
special case.

Why it doesn't hold up as-is

The 10 remaining / 7 sold => 17 total state in the issue above is
equally nonsensical; it is just unobservable, because there is no
stored total to contradict it. The invalid state wasn't prevented — the
ability to detect it was removed. That is strictly worse than a
validation error, which at least tells the organizer what went wrong.

The deeper problem is two writers on one field. amount_tickets is
decremented by sales (services.py:60) and overwritten wholesale by
organizer edits (views_api.py:355), with no coordination between them.
That is the mechanism behind the drift table in the issue body.

Under capacity-as-truth each field has exactly one writer: sales own
sold, the organizer owns capacity, and remaining is derived and
cannot drift by construction.

Related: services.py:59 is the only site in the extension that mutates
sold, and it only ever increments — so refunds never return inventory
today. Making them do so under remaining-as-truth means introducing a
third writer to amount_tickets; under capacity-as-truth it is just
decrementing sold, and remaining recomputes for free.

Proposal: separate the storage model from the input affordance

Store capacity, but keep the organizer-facing input as "tickets
still available for sale"
— the semantics of the current field. The
backend writes:

capacity = sold + input

This gives both properties at once:

  • Entering 0 still means "stop selling", exactly as today.
  • Capacity below sold becomes structurally impossible — no
    validation rule required, since sold + n >= sold for any n >= 0.
  • The total is always known and always correct, so the card can render
    3 of 10.

The organizer's mental model never has to include the word "capacity".
The data model does, because X of Y tickets left is a product
requirement that cannot be satisfied without storing Y.

Unchanged caveat

This does not help already-drifted rows. The backfill
capacity = amount_tickets + sold still lands this event at 17, and
intent is not recoverable from the data. A manual correction pass is
needed either way.

## Design note: keep the "remaining" input, store capacity Supersedes the "split the organizer form into distinct capacity / remaining fields" bullet in the proposed fix above. ### The case for the current remaining-as-truth model There is a real property worth preserving in the upstream model. Under capacity-as-truth, "set capacity to 40 when 85 are already sold" is a representable state, so something has to reject it. Under remaining-as-truth it is simply unsayable — every value in `[0, ∞)` is valid at every moment, and `0` cleanly means "stop selling" with no special case. ### Why it doesn't hold up as-is The `10 remaining / 7 sold => 17 total` state in the issue above is equally nonsensical; it is just *unobservable*, because there is no stored total to contradict it. The invalid state wasn't prevented — the ability to detect it was removed. That is strictly worse than a validation error, which at least tells the organizer what went wrong. The deeper problem is **two writers on one field**. `amount_tickets` is decremented by sales (`services.py:60`) *and* overwritten wholesale by organizer edits (`views_api.py:355`), with no coordination between them. That is the mechanism behind the drift table in the issue body. Under capacity-as-truth each field has exactly one writer: sales own `sold`, the organizer owns `capacity`, and `remaining` is derived and cannot drift by construction. Related: `services.py:59` is the only site in the extension that mutates `sold`, and it only ever increments — so refunds never return inventory today. Making them do so under remaining-as-truth means introducing a *third* writer to `amount_tickets`; under capacity-as-truth it is just decrementing `sold`, and remaining recomputes for free. ### Proposal: separate the storage model from the input affordance Store `capacity`, but keep the organizer-facing input as **"tickets still available for sale"** — the semantics of the current field. The backend writes: ``` capacity = sold + input ``` This gives both properties at once: - Entering `0` still means "stop selling", exactly as today. - Capacity below `sold` becomes **structurally impossible** — no validation rule required, since `sold + n >= sold` for any `n >= 0`. - The total is always known and always correct, so the card can render `3 of 10`. The organizer's mental model never has to include the word "capacity". The data model does, because `X of Y tickets left` is a product requirement that cannot be satisfied without storing `Y`. ### Unchanged caveat This does not help already-drifted rows. The backfill `capacity = amount_tickets + sold` still lands this event at 17, and intent is not recoverable from the data. A manual correction pass is needed either way.
Author
Owner

Client-side half filed as aiolabs/webapp#143 — the available + sold
derivation and the misleading "Tickets" form field. Postponed until the
backend semantics here are settled, since both the tag parsing and the
field label depend on the decision in this issue.

Minor correction to the code reference above and in the design-note
comment: the derivation is at src/modules/events/types/event.ts:98,
not :99.

Client-side half filed as `aiolabs/webapp#143` — the `available + sold` derivation and the misleading "Tickets" form field. Postponed until the backend semantics here are settled, since both the tag parsing and the field label depend on the decision in this issue. Minor correction to the code reference above and in the design-note comment: the derivation is at `src/modules/events/types/event.ts:98`, not `:99`.
Author
Owner

Upstream review — argues against the capacity column

Reviewed lnbits/events before planning work here (upstream/main @ 891eaf1, tags through v1.6.8; our merge-base is v1.6.1). It changes the calculus on the proposed fix.

Upstream has no ambiguity and no stored total. amount_tickets means remaining everywhere — services.py:95-105 decrements it on sale, and every capacity check is the same line (views_api.py:188, :415):

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

event.sold is never subtracted from it. There's no remaining = amount_tickets - sold upstream because CreateTicket has no quantity — one ticket per request. So the views_api.py:589-594 block this issue identifies is purely ours, introduced with the multi-quantity commit, exactly as diagnosed.

And v1.6.8 pushes further in that direction. Ticket waves give each wave its own amount_tickets, decremented per sale, with the event-level field becoming a derived roll-up (models.py:240):

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

Still remaining-semantics, just aggregated across waves.

A capacity column fights that. Under waves, "capacity" isn't one number on the event — it's per-wave, and the event-level value upstream computes is explicitly a sum of remaining. Adding a column that upstream has no concept of means owning the reconciliation in api_ticket_create forever, in the file #33 already calls "the hard one" because both sides rewrote it.

But this issue identifies a real problem that "just follow upstream" doesn't solve

The 10 of 17 evidence stands, and it's worth being precise about what causes it: it isn't the double-subtraction, it's that the webapp reconstructs a total that was never stored (event.ts:100, total = available + sold), and that reconstruction breaks the moment an organizer edits the event and re-baselines remaining.

Under strict upstream semantics there is no recoverable total, so the honest options are:

  1. Drop the total from the display. Render "45 tickets left", not "45 of 50". Matches what the data actually knows, needs no schema change, and survives waves untouched. The webapp keeps its available === undefined → unlimited branch as a fallback for foreign NIP-52 events from non-aio clients.
  2. Store capacity in extra JSON rather than as a column. If "X of Y" is genuinely wanted, upstream puts its own new features (waves, images, on-chain) in extra too, so an extra.capacity diverges far less than a real column and doesn't collide with the wave roll-up. Publish it as an explicit tickets_total tag so clients stop inferring it.

Either way the organizer-form trap this issue documents (field labeled "Tickets", prefilled with raw remaining, helper text "0 = unlimited") needs fixing — that's independent of which option wins, and it's what actually produced the 17.

Also settled: drop 0 = unlimited

Separate collision found while checking. models.py:96 documents 0 = unlimited / not ticketed and nostr_publisher.py:99-103 omits tickets_available to signal it, but views_api.py:287 in api_get_event is upstream's if event.amount_tickets < 1: 410 GONE. So a 0 event advertises "Unlimited tickets" on the card while the public detail endpoint refuses to serve it and every purchase is refused as sold out.

Decision (confirmed with @padreug): follow upstream, capacity always required, no unlimited. Free tickets are unaffected — they're gated on the computed charge (views_api.py:743, if price <= 0: _issue_free_tickets(...)), not on capacity, so a free event is price_per_ticket=0 with a real amount_tickets. The only thing lost is an uncapped event.

Sequencing

This should land after #33, not before. Both the double-subtraction fix and whichever total-display option wins live in api_ticket_create and the publisher — the two places the rebase rewrites. Doing it first means writing it twice and taking the conflict in the hunk we just touched.

(I filed #53 against this before spotting this issue — closed as a duplicate; its upstream review is reproduced above.)

## Upstream review — argues against the `capacity` column Reviewed `lnbits/events` before planning work here (`upstream/main` @ `891eaf1`, tags through v1.6.8; our merge-base is v1.6.1). It changes the calculus on the proposed fix. **Upstream has no ambiguity and no stored total.** `amount_tickets` means remaining everywhere — `services.py:95-105` decrements it on sale, and every capacity check is the same line (`views_api.py:188`, `:415`): ```python if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") ``` `event.sold` is never subtracted from it. There's no `remaining = amount_tickets - sold` upstream because `CreateTicket` has no `quantity` — one ticket per request. So the `views_api.py:589-594` block this issue identifies is purely ours, introduced with the multi-quantity commit, exactly as diagnosed. **And v1.6.8 pushes further in that direction.** Ticket waves give each wave its own `amount_tickets`, decremented per sale, with the event-level field becoming 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 across waves. A `capacity` column fights that. Under waves, "capacity" isn't one number on the event — it's per-wave, and the event-level value upstream computes is explicitly a sum of *remaining*. Adding a column that upstream has no concept of means owning the reconciliation in `api_ticket_create` forever, in the file #33 already calls "the hard one" because both sides rewrote it. ## But this issue identifies a real problem that "just follow upstream" doesn't solve The `10 of 17` evidence stands, and it's worth being precise about what causes it: it isn't the double-subtraction, it's that **the webapp reconstructs a total that was never stored** (`event.ts:100`, `total = available + sold`), and that reconstruction breaks the moment an organizer edits the event and re-baselines remaining. Under strict upstream semantics there *is* no recoverable total, so the honest options are: 1. **Drop the total from the display.** Render "45 tickets left", not "45 of 50". Matches what the data actually knows, needs no schema change, and survives waves untouched. The webapp keeps its `available === undefined → unlimited` branch as a fallback for foreign NIP-52 events from non-aio clients. 2. **Store capacity in `extra` JSON rather than as a column.** If "X of Y" is genuinely wanted, upstream puts its own new features (waves, images, on-chain) in `extra` too, so an `extra.capacity` diverges far less than a real column and doesn't collide with the wave roll-up. Publish it as an explicit `tickets_total` tag so clients stop inferring it. Either way the organizer-form trap this issue documents (field labeled "Tickets", prefilled with raw remaining, helper text "0 = unlimited") needs fixing — that's independent of which option wins, and it's what actually produced the 17. ## Also settled: drop `0 = unlimited` Separate collision found while checking. `models.py:96` documents `0 = unlimited / not ticketed` and `nostr_publisher.py:99-103` omits `tickets_available` to signal it, but `views_api.py:287` in `api_get_event` is upstream's `if event.amount_tickets < 1: 410 GONE`. So a `0` event advertises "Unlimited tickets" on the card while the public detail endpoint refuses to serve it and every purchase is refused as sold out. Decision (confirmed with @padreug): follow upstream, capacity always required, no unlimited. Free tickets are unaffected — they're gated on the computed charge (`views_api.py:743`, `if price <= 0: _issue_free_tickets(...)`), not on capacity, so a free event is `price_per_ticket=0` with a real `amount_tickets`. The only thing lost is an uncapped event. ## Sequencing This should land **after** #33, not before. Both the double-subtraction fix and whichever total-display option wins live in `api_ticket_create` and the publisher — the two places the rebase rewrites. Doing it first means writing it twice and taking the conflict in the hunk we just touched. (I filed #53 against this before spotting this issue — closed as a duplicate; its upstream review is reproduced above.)
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#34
No description provided.