feat(nostr): publish the active ticket wave, not the roll-up
Some checks failed
lint.yml / feat(nostr): publish the active ticket wave, not the roll-up (pull_request) Failing after 0s

Since upstream v1.6.8 price, currency and inventory belong to time-boxed
ticket waves, and the event-level fields `sync_event_ticket_waves`
derives are the PRIMARY wave's price/currency and the SUM of every
wave's stock. The NIP-52 publisher read those, so as soon as an
organiser created a second wave the public card would advertise the
early-bird price after early bird closed and count stock in waves that
had not opened. Refs #61.

`build_nip52_event` now describes the wave a buyer can actually buy
from:

- several waves can be open at once, and a publisher has no one to ask
  which one the buyer wants (the purchase endpoint errors with "Please
  select a ticket wave"), so it advertises the CHEAPEST open wave — the
  price a buyer is able to obtain. Deviation recorded in
  docs/upstream-candidates.md.
- with no open wave, `tickets_available` is 0 and never omitted:
  omission used to mean "unlimited", which #34/#62 removed as a concept.
- `tickets_payment_methods` is scoped to the advertised wave too. It was
  derived from `event.allow_fiat` — the primary wave's — so it could
  offer a fiat rail while `tickets_allow_fiat` was absent and the
  purchase endpoint would refuse it. They are the same fact and now come
  from the same place.

Wave boundaries are time-driven, and every republish we have is
sale-driven, so nothing fires when early bird ends at midnight. Rather
than add a scheduler, a publish records which wave it advertised
(`nostr_published_wave_id`, m004) and the reconciliation sweep compares
that against the wave that would be advertised now, setting
`nostr_publish_pending` on a mismatch — reusing the existing retry path.
NULL means "never published", which the sweep leaves alone so an upgrade
does not republish the whole table on first boot.

The selection rule lives in `models.advertised_ticket_wave` so the
publisher and the drift detector cannot disagree about what is on the
relay.

17 new tests; 114 pass. ruff, black, prettier clean; mypy error set
still identical to HEAD's baseline.
This commit is contained in:
Padreug 2026-09-28 22:28:09 +02:00
commit 15c2276e57
10 changed files with 525 additions and 39 deletions

View file

@ -17,7 +17,12 @@ from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import Event, effective_payment_methods
from .models import (
Event,
advertised_ticket_wave,
effective_payment_methods,
ensure_ticket_waves,
)
from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
@ -46,11 +51,13 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional)
image, location, t (categories) - optional
tickets_available - current remaining capacity (always emitted; 0 = sold out)
tickets_sold - running paid-count (always emitted; clients can
derive original_capacity = available + sold)
tickets_price - price_per_ticket (always emitted; 0 means free)
tickets_currency - the currency string
tickets_available - remaining capacity of the advertised wave
(always emitted; 0 = nothing on sale now)
tickets_sold - running paid-count across all waves (always
emitted)
tickets_price - the advertised wave's price (always emitted;
0 means free)
tickets_currency - the advertised wave's currency
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info
@ -103,21 +110,52 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
# the detail page and the purchase both returned 410 (aiolabs/events#34).
# Clients that must still handle an absent tag — a NIP-52 event from
# some other publisher — are unaffected; we simply never omit it.
tags.append(["tickets_available", str(event.amount_tickets)])
#
# Since v1.6.8 price, currency and inventory belong to a ticket WAVE.
# The event-level fields `sync_event_ticket_waves` derives are the
# PRIMARY wave's price/currency and the SUM of every wave's stock, so
# publishing them would keep advertising the early-bird price after
# early bird closed, and count stock in waves that have not opened yet
# (aiolabs/events#61). Advertise the wave a buyer can actually buy from.
#
# Several waves can be open at once. The purchase endpoint refuses to
# guess ("Please select a ticket wave"); a publisher has no one to ask,
# so it advertises the CHEAPEST open wave — the price a buyer is able to
# obtain right now. Deviation recorded in docs/upstream-candidates.md.
open_wave = advertised_ticket_wave(event)
# Nothing open: sold out, between waves, or not yet on sale. Fall back
# to the primary wave so price/currency still describe the event,
# paired with the zero availability below.
advertised = open_wave or ensure_ticket_waves(event)[0]
# Always emitted, including zero — see #34 above. With no open wave
# there is nothing to sell regardless of what the waves hold, so this
# is 0 rather than the wave's stock.
available = advertised.amount_tickets if open_wave else 0
tags.append(["tickets_available", str(available)])
# Event-level: total paid across every wave, which is what "sold" means
# to a reader of the card.
tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(event.price_per_ticket)])
tags.append(["tickets_currency", event.currency])
tags.append(["tickets_price", str(advertised.price_per_ticket)])
tags.append(["tickets_currency", advertised.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema.
if event.allow_fiat:
if advertised.allow_fiat:
tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency])
if advertised.fiat_currency:
tags.append(["tickets_fiat_currency", advertised.fiat_currency])
# Rails the organizer accepts, resolved through the same helper the
# ticket endpoint enforces with, so a client can render exactly the
# buttons that will be accepted (e.g. a card-only event) without a REST
# round-trip. Comma-separated, lowercase.
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
tags.append(
[
"tickets_payment_methods",
# Scoped to the advertised wave so this cannot contradict
# `tickets_allow_fiat` above — they are the same fact.
",".join(effective_payment_methods(event, advertised)),
]
)
# NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic