Ticket waves: manage them, buy from them, stop lying about totals #176

Merged
padreug merged 4 commits from feat/ticket-waves into dev 2026-09-29 07:23:44 +00:00
Owner

Full wave support. The events extension moved price, currency and stock onto time-boxed waves in v1.6.8 and made the event-level fields derived — the webapp still read them, so it was wrong in three separate ways.

Closes #143. Needs aiolabs/events #66 deployed (see Dependency below).

What was broken

Organizer edits were silently discarded. The backend derives amount_tickets and price_per_ticket from the waves on every write, so submitting changed values beside an unchanged wave list left the wave's old numbers winning. Verified against a running instance: 999 / 77 went in, 45 / 10.0 came back, HTTP 200, no error anywhere.

Multi-wave events couldn't be bought, and the price shown wasn't the price charged. Nothing sent ticket_wave_id, so an event with two open waves was refused outright ("Please select a ticket wave"). Worse when one wave was open: the dialog displayed event.price_per_ticket — the primary wave's — while the backend charged the active one. After early bird closed, the webapp showed the old price and charged the new.

The ticket total was fiction. "{available} of {total}" derived total as available + sold. Already wrong after any edit re-baselined remaining (#143); now meaningless, since tickets_available is the active wave's stock while tickets_sold counts every wave. 5 left in the open wave with 7 sold earlier displayed "5 of 12".

The four commits

  1. 8490cdf types + arithmetic. lib/ticketWaves.ts mirrors the extension's ensure_ticket_waves, get_active_ticket_waves, advertised_ticket_wave, sync_event_ticket_waves and the _resolve_ticket_wave selection rule.
  2. afa34c9 checkout. Resolves a wave the way the backend does — one open wave implied, several means the buyer picks, none means nothing on sale. Price, currency and allow_fiat all read from that wave. ticket_wave_id rides on all three purchase paths and the promo preview, so quote and charge price the same tier.
  3. d55115d organizer form. The price / capacity / currency fields are the primary wave, so they're written into it on submit. A new "Ticket waves" section manages later tiers, mirroring PromoCodesEditor.
  4. 072ca88 display. Total removed rather than recomputed.

Two things worth a reviewer's attention

The mirror was cross-checked, not assumed. Every function in ticketWaves.ts was run against the live Python with shared fixtures. That caught a divergence I'd otherwise have shipped: the backend keeps the time component on a synthesized primary wave's closing_date, and sync_event_ticket_waves takes a string max over those. Truncating to a date — the obvious-looking thing, and what I wrote first — makes the client derive a different event closing date than the server. Truncation belongs at comparison time, in waveDay, mirroring _parse_date.

A crash caught while wiring the write-through. On create there's no stored event, so the implied primary wave was synthesized with empty date strings — which the backend throws on for every subsequent read (ValueError: Invalid isoformat string: ''). The seed now supplies the same inputs create_event uses. Pinned by a test.

Wave validation follows _validate_wave_capacity including its subtlety: a wave already stored may sit at zero capacity, because that's what sold out looks like and rejecting it would make a sold-out event uneditable. Only newly added waves must state a capacity; stored zeroes render as "Sold out".

Dependency

Needs aiolabs/events#66 (ticket_waves on public event responses). Without it only the organizer can see waves, because the NIP-52 tags describe the active wave but carry no wave id — so the picker would never render for a buyer. The detail page fetches the LNbits record for the pickable tiers and treats failure as non-fatal (the backend answers 410 for sold-out events, where there's no tier to pick anyway).

Verification

83 tests pass (24 new), vue-tsc clean, prettier clean on every touched file, production build succeeds.

Not yet exercised against a running stack — that needs events#66 deployed to the dev LNbits plus a npm run dev session. Worth doing on dev/aio-demo before this reaches main: create a two-wave event, check the picker appears, buy from the dearer wave and confirm the invoice matches it.

Full wave support. The events extension moved price, currency and stock onto time-boxed waves in v1.6.8 and made the event-level fields derived — the webapp still read them, so it was wrong in three separate ways. Closes #143. Needs `aiolabs/events` #66 deployed (see **Dependency** below). ## What was broken **Organizer edits were silently discarded.** The backend derives `amount_tickets` and `price_per_ticket` *from* the waves on every write, so submitting changed values beside an unchanged wave list left the wave's old numbers winning. Verified against a running instance: 999 / 77 went in, 45 / 10.0 came back, HTTP 200, no error anywhere. **Multi-wave events couldn't be bought, and the price shown wasn't the price charged.** Nothing sent `ticket_wave_id`, so an event with two open waves was refused outright ("Please select a ticket wave"). Worse when one wave was open: the dialog displayed `event.price_per_ticket` — the *primary* wave's — while the backend charged the active one. After early bird closed, the webapp showed the old price and charged the new. **The ticket total was fiction.** "{available} of {total}" derived total as `available + sold`. Already wrong after any edit re-baselined remaining (#143); now meaningless, since `tickets_available` is the *active wave's* stock while `tickets_sold` counts every wave. 5 left in the open wave with 7 sold earlier displayed "5 of 12". ## The four commits 1. **`8490cdf` types + arithmetic.** `lib/ticketWaves.ts` mirrors the extension's `ensure_ticket_waves`, `get_active_ticket_waves`, `advertised_ticket_wave`, `sync_event_ticket_waves` and the `_resolve_ticket_wave` selection rule. 2. **`afa34c9` checkout.** Resolves a wave the way the backend does — one open wave implied, several means the buyer picks, none means nothing on sale. Price, currency and `allow_fiat` all read from that wave. `ticket_wave_id` rides on all three purchase paths and the promo preview, so quote and charge price the same tier. 3. **`d55115d` organizer form.** The price / capacity / currency fields *are* the primary wave, so they're written into it on submit. A new "Ticket waves" section manages later tiers, mirroring `PromoCodesEditor`. 4. **`072ca88` display.** Total removed rather than recomputed. ## Two things worth a reviewer's attention **The mirror was cross-checked, not assumed.** Every function in `ticketWaves.ts` was run against the live Python with shared fixtures. That caught a divergence I'd otherwise have shipped: the backend *keeps* the time component on a synthesized primary wave's `closing_date`, and `sync_event_ticket_waves` takes a **string** max over those. Truncating to a date — the obvious-looking thing, and what I wrote first — makes the client derive a different event closing date than the server. Truncation belongs at comparison time, in `waveDay`, mirroring `_parse_date`. **A crash caught while wiring the write-through.** On create there's no stored event, so the implied primary wave was synthesized with empty date strings — which the backend throws on for every subsequent read (`ValueError: Invalid isoformat string: ''`). The seed now supplies the same inputs `create_event` uses. Pinned by a test. Wave validation follows `_validate_wave_capacity` including its subtlety: a wave **already stored** may sit at zero capacity, because that's what sold out looks like and rejecting it would make a sold-out event uneditable. Only newly added waves must state a capacity; stored zeroes render as "Sold out". ## Dependency Needs **aiolabs/events#66** (`ticket_waves` on public event responses). Without it only the organizer can see waves, because the NIP-52 tags describe the active wave but carry no wave id — so the picker would never render for a buyer. The detail page fetches the LNbits record for the pickable tiers and treats failure as non-fatal (the backend answers 410 for sold-out events, where there's no tier to pick anyway). ## Verification 83 tests pass (24 new), `vue-tsc` clean, prettier clean on every touched file, production build succeeds. Not yet exercised against a running stack — that needs events#66 deployed to the dev LNbits plus a `npm run dev` session. Worth doing on `dev`/aio-demo before this reaches main: create a two-wave event, check the picker appears, buy from the dearer wave and confirm the invoice matches it.
Foundation for wave support. Types only plus a pure lib — no UI yet.

Since events ext v1.6.8 price, currency and stock belong to a ticket
WAVE, and the event-level fields are derived: `amount_tickets` is the sum
across waves, `price_per_ticket` and `currency` are the FIRST wave's.
Reading them to price or count anything is now a bug, which is what the
webapp does today.

`lib/ticketWaves.ts` mirrors the extension's `ensure_ticket_waves`,
`get_active_ticket_waves`, `advertised_ticket_wave` and
`sync_event_ticket_waves`, plus the `_resolve_ticket_wave` selection rule
and the primary-wave write-through the LNbits admin dialog performs.

Every function was cross-checked against the running Python with shared
fixtures rather than written from the docs, which caught one divergence
worth recording: the backend keeps the time component on a synthesized
primary wave's `closing_date`, and `sync_event_ticket_waves` takes a
STRING max over those. Truncating to a date — the obvious-looking
thing, and what this first did — makes the client derive a different
event closing_date than the server. Truncation belongs at comparison
time, in `waveDay`, mirroring the backend's `_parse_date`.

`ticket_wave_id` added to CreateTicketRequest and PromoValidateRequest;
`ticket_waves` to EventExtra. The latter is public from events ext
v1.6.8-aio.2 (aiolabs/events#66) — without that a buyer cannot learn a
wave id at all.

19 tests. vue-tsc and prettier clean; 59 events tests pass.
Checkout was priced and requested off the event, which since events ext
v1.6.8 means the PRIMARY wave. Two things were wrong at once: once early
bird closed the dialog displayed the closed tier's price while the
backend charged the open one, and an event with two waves open could not
be bought at all — the purchase endpoint refuses to guess ("Please
select a ticket wave") and nothing sent `ticket_wave_id`.

The dialog now resolves a wave the way the backend does: a single open
wave is implied, several means the buyer picks, none means nothing is on
sale. Price, currency and fiat availability all read from that wave —
`allow_fiat` too, which is a per-wave opt-in and could otherwise offer a
rail the purchase would refuse. The picker only renders when there is an
actual choice, so the common single-wave case gains no extra click, and
the CTA is disabled rather than firing a request the backend will reject.

`ticket_wave_id` now rides on all three purchase paths (discounted-free,
Lightning, fiat) and on the promo preview, so the quote and the charge
are priced against the same tier.

Plumbing worth noting: the tags a card renders from describe the active
wave but carry no wave id, by the flat-tag decision on aiolabs/events#61.
So the detail page fetches the LNbits record for the pickable tiers —
possible only because `ticket_waves` is public from v1.6.8-aio.2
(aiolabs/events#66). Non-fatal on failure: the backend answers 410 for
sold-out or closed events, where there is no tier to pick anyway.

3 service tests pin the wire format, including that no empty wave id is
sent when the buyer had no choice. 62 pass; vue-tsc and prettier clean.
Capacity, price, currency and fiat edits made in the webapp were
silently discarded. Since events ext v1.6.8 the backend derives those
event-level fields FROM the waves on every write, so submitting a
changed `amount_tickets` beside an unchanged wave list left the wave's
old number winning. Verified against a running instance before fixing:
999 / 77 went in, 45 / 10.0 came back, HTTP 200.

The dialog's price / capacity / currency fields ARE the primary wave, so
they are now written into it on submit — the same write-through the
LNbits admin dialog performs. A new "Ticket waves" section manages the
tiers after the first, mirroring PromoCodesEditor: v-model over a row
array, with the pure `validateWaveRows` so submit gates on exactly the
rules the backend enforces.

Validation follows `_validate_wave_capacity`, including its subtlety: a
wave already stored on the event may sit at zero capacity, because that
is what sold out looks like and rejecting it would make a sold-out event
uneditable. Only a newly added wave must state a real capacity. Stored
zeroes render as "Sold out" rather than as an error.

One hazard found while wiring the write-through: on CREATE there is no
stored event, so the implied primary wave was synthesized with empty
date strings — which the backend then throws on for every read
(`ValueError: Invalid isoformat string: ''`). The seed now supplies the
same inputs `create_event` uses, so the wave the webapp sends matches
the one the backend would have built. Pinned by a test.

72 tests; vue-tsc and prettier clean.
The card and detail page rendered "{available} of {total} tickets left",
where total was derived as `available + sold`. That was already wrong
after any organizer edit re-baselined remaining (#143), and waves make
it meaningless: since events ext v1.6.8 `tickets_available` is the
ACTIVE WAVE's stock while `tickets_sold` counts every wave, so the two
no longer share a denominator. An event with 5 left in the open wave and
7 sold across earlier ones displayed "5 of 12".

Nothing publishes a capacity — no tag, no field — so nothing can honestly
show one. Both sites now use the existing `ticketsAvailable` string,
which says only what the data knows.

`EventTicketInfo.total` and the `ticketsRemainingOfTotal` string are
removed rather than left unused, so neither can be picked up again.

Closes #143. Whether to publish a real capacity remains open on
aiolabs/events#34, which narrowed it to either dropping the total (this)
or storing capacity and publishing an explicit `tickets_total` tag.
padreug deleted branch feat/ticket-waves 2026-09-29 07:23:45 +00:00
padreug referenced this pull request from a commit 2026-09-29 07:24:41 +00:00
padreug referenced this pull request from a commit 2026-09-29 07:25:21 +00:00
Sign in to join this conversation.
No description provided.