diff --git a/config.json b/config.json index f356813..f7e0ec1 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.8-aio.1", + "version": "1.6.8-aio.4", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/models.py b/models.py index 1768b44..c5a1131 100644 --- a/models.py +++ b/models.py @@ -58,7 +58,8 @@ class TicketWave(BaseModel): class EventExtraBase(BaseModel): - """Everything in `extra` that is safe to show anyone. `EventExtra` adds + """Everything in `extra` that is safe to show anyone — ticket waves + included, since a buyer needs a wave id to choose one. `EventExtra` adds the organizer-only promo codes on top; `PublicEventExtra` is this base, so anonymous responses can never carry them.""" @@ -73,6 +74,19 @@ class EventExtraBase(BaseModel): # `effective_payment_methods`. Same field name/shape as upstream v2 so the # eventual rebase (#33) merges cleanly. payment_methods: list[str] = Field(default_factory=list) + # Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The + # event-level `currency` / `allow_fiat` / `amount_tickets` / + # `price_per_ticket` fields become derived values (see + # `sync_event_ticket_waves`), which is why fork code that reads them + # needs auditing — aiolabs/events#61. + # + # Public, not organizer-only: a buyer cannot choose a wave without its + # id, and neither the public event response nor the NIP-52 tags carried + # one before. A wave holds price, dates and remaining stock — the sales + # information a buyer needs — so the only thing exposing it reveals is + # the upcoming price schedule, which is what #61 regretted giving up + # when it settled on flat Nostr tags. + ticket_waves: list[TicketWave] = Field(default_factory=list) @validator("payment_methods", pre=True) def normalize_payment_methods(cls, v): @@ -92,12 +106,6 @@ class EventExtraBase(BaseModel): class EventExtra(EventExtraBase): promo_codes: list[PromoCode] = Field(default_factory=list) - # Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The - # event-level `currency` / `allow_fiat` / `amount_tickets` / - # `price_per_ticket` fields become derived values (see - # `sync_event_ticket_waves`), which is why fork code that reads them - # needs auditing — aiolabs/events#61. - ticket_waves: list[TicketWave] = Field(default_factory=list) PublicEventExtra = EventExtraBase @@ -216,6 +224,14 @@ def effective_payment_methods( """ explicit = list(getattr(event.extra, "payment_methods", []) or []) if explicit: + # The organiser's rail list is event-level, but fiat is a per-wave + # opt-in. Asking about a specific wave means asking what a buyer can + # actually use for it, so drop a rail that wave cannot honour — + # otherwise the NIP-52 tag advertises fiat and the checkout offers a + # card button that `api_ticket_create` then refuses with "Fiat + # payments are not enabled for this ticket wave." + if wave is not None and not wave.allow_fiat: + return [method for method in explicit if method != "fiat"] return explicit methods = ["lightning"] if wave.allow_fiat if wave is not None else event.allow_fiat: diff --git a/static/js/index.js b/static/js/index.js index edddaba..fc6e807 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -414,6 +414,26 @@ window.PageEvents = { this.ticketImageUploadTarget = null } }, + waveSummary(eventId, wave) { + // Built here, not in the template. Vue resolves template expressions + // against the component instance, where the `LNbits` global is NOT + // in scope — upstream's v1.6.8 chip called `LNbits.utils` inline and + // threw "Cannot read properties of undefined (reading 'utils')", + // which killed the whole v-for and left the wave list looking empty. + // No other template in this extension touches `LNbits` directly. + const price = this.isFiatCurrency(wave.currency) + ? LNbits.utils.formatCurrency( + Number(wave.price_per_ticket || 0).toFixed(2), + wave.currency + ) + : `${wave.price_per_ticket} sats` + // Wave dates can carry a time (closing_date defaults from + // event_end_date); show the day only. + const opens = String(wave.opening_date || '').slice(0, 10) + const closes = String(wave.closing_date || '').slice(0, 10) + const sold = this.soldTicketsForWave(eventId, wave.id) + return `${wave.title} - ${opens} to ${closes} - ${price} - ${wave.amount_tickets} tickets - ${sold} sold` + }, soldTicketsForWave(eventId, waveId) { return this.allPaidTickets.filter( ticket => diff --git a/static/js/index.vue b/static/js/index.vue index 3ffe9bb..4795a3c 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -271,23 +271,7 @@ > diff --git a/tests/test_publish_active_wave.py b/tests/test_publish_active_wave.py index 7c73781..964a0c4 100644 --- a/tests/test_publish_active_wave.py +++ b/tests/test_publish_active_wave.py @@ -158,3 +158,38 @@ def test_advertised_wave_key_tracks_the_published_wave(): # Published while nothing is on sale — a real state, distinct from the # NULL that means "never published". assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == "" + + +def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it(): + """The webapp always sets `extra.payment_methods`, so the explicit-list + path is the normal one — and it used to ignore the wave entirely. + + That published `tickets_payment_methods: lightning,fiat` beside an + absent `tickets_allow_fiat`, and a card button the purchase endpoint + then refused ("Fiat payments are not enabled for this ticket wave"). + """ + event = _event( + [ + _wave("a", 10.0, 0, -10, -1, allow_fiat=True), + _wave("b", 25.0, 9, 0, 20, allow_fiat=False), + ] + ) + event.extra.payment_methods = ["lightning", "fiat"] + tags = _tags(event) + + assert "tickets_allow_fiat" not in tags + assert tags["tickets_payment_methods"] == "lightning" + + +def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it(): + event = _event( + [ + _wave("a", 10.0, 0, -10, -1, allow_fiat=False), + _wave("b", 25.0, 9, 0, 20, allow_fiat=True), + ] + ) + event.extra.payment_methods = ["lightning", "fiat"] + tags = _tags(event) + + assert tags["tickets_allow_fiat"] == "true" + assert tags["tickets_payment_methods"] == "lightning,fiat" diff --git a/tests/test_ticket_models.py b/tests/test_ticket_models.py index 7d1ab3f..6d782d2 100644 --- a/tests/test_ticket_models.py +++ b/tests/test_ticket_models.py @@ -5,6 +5,10 @@ from ..models import ( CreateEvent, CreateTicket, EventExtra, + PromoCode, + PublicEvent, + PublicEventExtra, + TicketWave, effective_payment_methods, ) @@ -108,3 +112,108 @@ def test_payment_methods_are_normalised_and_deduplicated(): def test_unknown_payment_method_is_rejected(): with pytest.raises(ValidationError): EventExtra(payment_methods=["cash"]) + + +# --- public projection ------------------------------------------------------ + + +def test_public_extra_exposes_waves_but_never_promo_codes(): + """A buyer needs a wave id to choose a tier, so waves are public; promo + codes stay organizer-only (aiolabs/events#61, v1.6.1-aio.12).""" + organizer = EventExtra( + promo_codes=[PromoCode(code="SECRET", discount_percent=50)], + ticket_waves=[ + TicketWave( + id="early", + title="Early", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="sat", + price_per_ticket=10, + amount_tickets=5, + ) + ], + ) + + public = PublicEventExtra(**organizer.dict()) + + assert [wave.id for wave in public.ticket_waves] == ["early"] + assert public.ticket_waves[0].price_per_ticket == 10 + assert not hasattr(public, "promo_codes") + assert "promo_codes" not in public.dict() + + +def test_public_event_response_carries_waves(): + """End of the chain: what `GET /events/{id}` actually serialises.""" + wave = TicketWave( + id="regular", + title="Regular", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="sat", + price_per_ticket=25, + amount_tickets=40, + ) + organizer_extra = EventExtra( + ticket_waves=[wave], promo_codes=[PromoCode(code="SECRET")] + ) + public = PublicEvent( + id="evt", + name="Test", + info="", + canceled=False, + event_start_date="2030-01-01", + currency="sat", + price_per_ticket=25, + banner=None, + extra=PublicEventExtra(**organizer_extra.dict()), + ) + body = public.dict() + + assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"] + assert "promo_codes" not in body["extra"] + + +# --- rails vs per-wave fiat -------------------------------------------------- + + +def _fiat_wave(allow_fiat: bool): + from ..models import TicketWave + + return TicketWave( + id="w", + title="w", + opening_date="2030-01-01", + closing_date="2030-02-01", + currency="EUR", + price_per_ticket=10, + amount_tickets=5, + allow_fiat=allow_fiat, + ) + + +def test_explicit_rails_drop_fiat_for_a_wave_that_cannot_take_it(): + """The organiser's rail list is event-level; fiat is per-wave. + + Without this the NIP-52 tag advertises fiat and the checkout renders a + card button that `api_ticket_create` refuses with "Fiat payments are + not enabled for this ticket wave" (reported on aio-demo). + """ + event = _event() + event.extra.payment_methods = ["lightning", "fiat"] + + assert effective_payment_methods(event, _fiat_wave(True)) == ["lightning", "fiat"] + assert effective_payment_methods(event, _fiat_wave(False)) == ["lightning"] + + +def test_event_level_question_still_reports_every_rail(): + """No wave means "what did the organiser enable at all" — unfiltered.""" + event = _event() + event.extra.payment_methods = ["lightning", "fiat"] + assert effective_payment_methods(event) == ["lightning", "fiat"] + + +def test_fiat_only_rails_on_a_non_fiat_wave_leave_nothing_purchasable(): + event = _event() + event.extra.payment_methods = ["fiat"] + assert effective_payment_methods(event, _fiat_wave(False)) == [] diff --git a/tests/test_wave_preservation_on_edit.py b/tests/test_wave_preservation_on_edit.py new file mode 100644 index 0000000..1ab1b96 --- /dev/null +++ b/tests/test_wave_preservation_on_edit.py @@ -0,0 +1,124 @@ +"""Editing an event must not destroy its ticket waves. + +`api_event_update` replaces `extra` wholesale, so a client that rebuilds the +envelope rather than round-tripping it used to wipe every wave: the list +landed empty, `ensure_ticket_waves` synthesized one primary wave from the +event-level `amount_tickets`, and a multi-wave event silently collapsed to a +single tier carrying whatever that client happened to send. Same hazard the +`promo_codes` guard already covered. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave + +TODAY = datetime.now(timezone.utc).date() + + +def _day(offset: int) -> str: + return (TODAY + timedelta(days=offset)).isoformat() + + +def _wave(wave_id: str, price: float, stock: int) -> TicketWave: + return TicketWave( + id=wave_id, + title=wave_id, + opening_date=_day(0), + closing_date=_day(20), + currency="sat", + price_per_ticket=price, + amount_tickets=stock, + ) + + +STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)] + + +def _stored_event() -> Event: + return Event( + id="evt", + wallet="w", + name="Fete", + info="", + closing_date=_day(20), + event_start_date=_day(30), + currency="sat", + price_per_ticket=10, + amount_tickets=45, + time=datetime.now(timezone.utc), + extra=EventExtra( + ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")] + ), + ) + + +def _incoming(extra_payload: dict) -> CreateEvent: + """A request built from raw JSON, so `__fields_set__` reflects exactly + which keys the client actually sent.""" + return CreateEvent( + wallet="w", + name="Fete", + info="", + event_start_date=_day(30), + currency="sat", + price_per_ticket=77, + amount_tickets=999, + extra=EventExtra(**extra_payload), + ) + + +def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent: + """The carry-over as `api_event_update` performs it.""" + if "ticket_waves" not in data.extra.__fields_set__: + data.extra.ticket_waves = event.extra.ticket_waves + return data + + +def test_client_that_omits_waves_keeps_them(): + """The regression: a client rebuilding `extra` from scratch.""" + data = _apply_guard(_incoming({"email_notifications": False}), _stored_event()) + assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"] + assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25] + + +def test_client_that_sends_waves_still_wins(): + replacement = [_wave("solo", 30, 12)] + data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event()) + assert [w.id for w in data.extra.ticket_waves] == ["solo"] + + +def test_explicit_empty_list_still_resets(): + """Matches the promo_codes contract: naming the key means you meant it.""" + data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event()) + assert data.extra.ticket_waves == [] + + +def test_guard_runs_before_capacity_validation(): + """Validation must see the carried-over waves. + + Without the ordering, a client omitting both the waves and a real + capacity would be rejected for a zero-capacity primary wave that only + existed because its waves had just been dropped. + """ + from ..views_api import _validate_wave_capacity + + stored = _stored_event() + data = _incoming({"email_notifications": False}) + data.amount_tickets = 0 + + _validate_wave_capacity(_apply_guard(data, stored), stored) + + +@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"]) +def test_both_organiser_owned_extra_fields_are_guarded(key): + """Regression net: `extra` holds organiser state a client need not know + about, and every such field needs the same carry-over.""" + stored = _stored_event() + data = _incoming({"email_notifications": False}) + if "promo_codes" not in data.extra.__fields_set__: + data.extra.promo_codes = stored.extra.promo_codes + _apply_guard(data, stored) + + assert getattr(data.extra, key), f"{key} was dropped on edit" diff --git a/views_api.py b/views_api.py index 91a03f3..94b5bad 100644 --- a/views_api.py +++ b/views_api.py @@ -416,6 +416,20 @@ async def api_event_update( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + # Carry the stored waves over unless the request names the key. `extra` is + # replaced wholesale below, so a client that rebuilds the envelope instead + # of round-tripping it would otherwise destroy every wave: the list lands + # empty, `ensure_ticket_waves` synthesizes one primary wave from the + # event-level `amount_tickets`, and a multi-wave event silently collapses + # to a single tier carrying whatever numbers that client happened to send. + # Same hazard and same fix as `promo_codes` below — organiser-managed + # state living in `extra` that a client need not know about. Must run + # BEFORE `_validate_wave_capacity`, so validation sees the waves the event + # will actually end up with. An explicit `[]` still resets, as it does for + # promo codes. + if "ticket_waves" not in data.extra.__fields_set__: + data.extra.ticket_waves = event.extra.ticket_waves + _validate_wave_capacity(data, event) from lnbits.settings import settings