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.
Brings in ticket waves (per-wave price/currency/stock/fiat), the paginated
ticket endpoint, the organiser ticket-image template, and the SatsPay
on-chain surface. Refs #33.
Resolutions that were not mechanical, and why:
- set_ticket_paid debits the wave named on the ticket, keeping upstream's
`> 0` guards; ours decremented unconditionally and could go negative.
The purchase and free-ticket paths now stamp ticket_wave_id /
ticket_wave_title, without which every sale would debit the primary wave.
- Pricing moved onto the selected wave (basket_totals takes it as a required
argument, the promo-validate endpoint resolves the same wave through the
shared _resolve_ticket_wave). event.price_per_ticket is a roll-up of the
PRIMARY wave since sync_event_ticket_waves, so pricing off the event
quoted and charged the first wave's price to buyers who picked a later
one. Regression test added.
- Kept npub support in two places upstream removed it: the purchase
endpoint's normalize_public_key path and the notification dispatcher.
Upstream's replacement rejects with "Only NIP-05 Nostr identifiers are
supported", which is false for this fork. The purchase-side rejection had
merged in outside any conflict marker.
- _ticket_image_url existed on both sides as two unrelated features. Ours
(always-attached rendered QR card) is now _ticket_card_url; upstream's
(organiser template, opt-in per wave) keeps the name. Both are wired into
the mail, and the /qr/{ticket_id} endpoint — also duplicated on both
sides, on the same route — is merged into one handler rather than
registered twice, where the second copy would have been unreachable.
- models._parse_date now accepts a full ISO datetime. Upstream's date-only
strptime raised ValueError on any event whose closing_date carries a time,
which create_event produces by defaulting it from event_end_date — it
would have 500'd the purchase path, the public event gate and the promo
preview. Reproduced before fixing.
- Dropped upstream's inline make_qr_png (we import a superset from .qr) and
its duplicate paymentMethodOptions in display.js, which re-derived payment
options from per-method booleans and offered an on-chain option the
backend rejects; the submit gate now matches the template's condition.
- Restored imports the merge silently dropped with upstream's npub removal
(normalize_public_key, normalize_private_key, DEFAULT_NOSTR_RELAYS).
Event create/update stays ours: upstream's combined endpoint would have
replaced the approval workflow and the explicit field allowlist that keeps
`status` out of the request body.
mypy error set is unchanged from HEAD; ruff, black and the 97 tests pass.
A flagged row recovers on its own instead of waiting for the next sale
that happens to land while the signer is healthy — or for an operator
who already knows to run /republish-all, which was the only recovery
path and requires knowing about drift that nothing reported.
Retrying from the DB rather than an in-memory queue means the retry
survives a restart, and it needs no theory about why the publish didn't
land: the sweep covers the signer outage of #35 and the silent skip of
#51 identically, along with causes nobody has hit yet.
Runs every 5 minutes, take-down branch mirroring the publish/delete
split the CRUD endpoints already use. Quiet by design — on a healthy
instance the query returns nothing and it logs nothing.
Refs #35
`CreateTicket` no longer rejects `user_id` together with `name`/`email`
(the exclusion was a fork-only dispatch convenience from dfabcb8; nothing
needed it). `crud.create_ticket` stops blanking name/email when a user_id
is present, so logged-in webapp buyers can have their ticket emailed —
until now `_send_ticket_notification` short-circuited on the empty
address for every app purchase.
New optional `frontend_url` (absolute http(s) root, no query/fragment/..,
trailing slash stripped) lets a buyer-side client name the app the buyer
should be returned to and linked into from the ticket email; the origin
allow-list lives in views_api.
Also folds in the pending black reflow of migrations_fork.py.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Second nostr-transport handler on this branch. Returns paid + registered
counts plus the per-ticket roster (id, name, registered status, timestamp)
for one calendar event, organizer-only.
Backs the door scanner's counts strip and "scanned" list with backend
truth so a second organizer scanning on another device, an operator
switching from mobile to laptop mid-event, or a refresh in incognito
all see the same numbers instead of diverging from a per-device
localStorage cache.
Same authorisation posture as events_ticket_register: dispatcher
binds caller pubkey to wallet via AUTH_WALLET, handler verifies the
event's wallet is in the caller's wallet set. Only paid tickets land
in the response — proposed/unpaid rows are irrelevant at the door.
Webapp consumes this in aiolabs/webapp#73.
Replaces the previous "one row, N seats via extra.quantity" model
with proper one-row-per-attendee semantics. Each attendee gets a
unique scannable id; the door PUT /register/{ticket_id} marks
them registered independently — so a buyer can purchase 3 tickets,
hand 2 QRs to friends arriving separately, and each attendee can
enter on their own schedule.
Schema (migrations_fork.py m002):
- ticket.payment_hash: new TEXT column shared across all rows of
a multi-ticket purchase. Backfilled `payment_hash = id` for
pre-migration rows (id WAS the payment_hash by invariant).
Wire:
- TicketPaymentRequest grows `ticket_ids: list[str]` so the
webapp gets every scannable id back in the create response.
- POST /tickets/{event_id}/{payment_hash} polling endpoint now
reports `ticket_ids` (every row) + keeps `ticket_id` for
back-compat.
- api_ticket_create loops quantity times; the first row reuses
payment_hash as id (preserves legacy `id == payment_hash`
invariant for single-ticket purchases), the rest get
urlsafe_short_hash() uuids.
Payment flow:
- on_invoice_paid fetches all rows by payment_hash and marks each
paid via set_ticket_paid, which now increments event.sold by 1
per row (was N per row via extra.quantity — simpler now). The
per-event asyncio lock still serializes counter + republish so
concurrent multi-ticket purchases for the same event don't
reorder the published Nostr state.
- Each paid row triggers its own send_ticket_notification_in_
background call — no-op for buyers without nostr_identifier /
email, useful when the buyer set those on the row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Non-admin event submissions now land in a "proposed" queue that LNbits
admins review before the event becomes ticketable and publicly listed.
- m008 adds events.events.status (proposed/approved/rejected); m010 seeds
an events.settings singleton row with the auto_approve toggle.
- Models: Event/CreateEvent.status, EventsSettings, optional date fields
with sensible defaults (closing_date defaults to event_end_date which
defaults to event_start_date), PublicEvent.status surfaces the workflow
state on the public endpoint.
- crud: get_all/public/pending_events for the admin views; get/update_settings
for the auto_approve toggle; create_event auto-fills missing date defaults.
- views_api:
* POST /api/v1/events accepts wallet invoice keys so anyone can submit;
handler stamps status="proposed" for non-admins when auto_approve is off
* /public, /all, /pending, /settings (GET+PUT), /{id}/{approve,reject},
/{id}/tickets endpoints; literal-prefix routes declared before /{event_id}
so FastAPI matches them correctly
* Public GET /{event_id} bypasses sold-out / closing-window gates for
proposed/rejected events and returns the trimmed PublicEvent so the SFC
can render a "pending approval" banner
* POST /tickets/{event_id} rejects when event.status != "approved"
- Frontend: index.vue gains an admin Settings card, Pending Approvals list,
status badge column and approve/reject row actions, plus an All Users'
Events admin table; index.js gains the data + methods + an isAdmin probe
via GET /events/all; display.vue shows pending/rejected banners and
hides the Buy Ticket form unless status === "approved".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an alternative ticket identifier scheme: instead of (name, email),
external integrations can issue tickets bound to an LNbits user_id.
- m007 adds the user_id column on events.ticket
- CreateTicket validator enforces exactly one identifier scheme per ticket
- Ticket / PublicTicket: name, email, user_id all Optional
- _parse_ticket_row reverses the empty-string sentinel used to keep the
NOT NULL name/email columns satisfied when user_id is the identifier
- POST /tickets/{event_id} dispatches to _create_user_id_ticket vs
_create_named_ticket based on the supplied identifier
- New GET /tickets/user/{user_id} returns tickets for a given user
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>