A buyer cannot choose a ticket wave without its id, and nothing public
carried one: `PublicEventExtra` is `EventExtraBase`, which did not
include `ticket_waves`, and the NIP-52 tags deliberately publish only
the active wave with no identifier (#61). So `GET /events/{id}` and
`/events/public` gave a client everything except the one field it needs
to say which tier it wants — and the purchase endpoint refuses when
several waves are open.
Moves `ticket_waves` from `EventExtra` to `EventExtraBase`, leaving
`promo_codes` as the only organizer-private field in `extra`.
A wave holds an id, title, date window, currency, price, remaining stock
and the fiat flags — the sales information a buyer needs in order to
choose. The only thing publishing it reveals is the upcoming price
schedule, which is precisely what #61 recorded as the cost of settling
on flat Nostr tags; over REST it is a gain rather than a leak.
Prerequisite for wave support in the webapp, which otherwise cannot
offer a wave picker to anyone but the organizer.
Two tests pin the projection: the public extra carries waves and never
promo codes, and a serialised `PublicEvent` shows the same.
`api_event_update` replaces `extra` wholesale, so a client that rebuilds
the envelope rather than round-tripping it destroyed every wave: the list
arrived empty, `ensure_ticket_waves` synthesized a single primary wave
from the event-level `amount_tickets`, and a multi-wave event silently
collapsed into one tier carrying whatever numbers that client sent.
Reproduced against a running instance before fixing — a PUT whose `extra`
omitted the key turned a two-wave event into:
amount_tickets=999 price=77.0 waves=1
primary "Primary wave" 77.0 999
`promo_codes` has carried the same guard since the v1.6.8 merge, for the
same reason and in the same function; `ticket_waves` is the same class of
state — organiser-managed, living in `extra`, and invisible to a client
that does not implement it. I added the first and did not extend the
reasoning to the second.
The carry-over runs before `_validate_wave_capacity` so validation sees
the waves the event will actually end up with; otherwise a client that
omitted both the waves and a real capacity would be rejected for a
zero-capacity primary wave that existed only because its waves had just
been dropped. An explicit `[]` still resets, matching promo codes.
Note the aio webapp is not what surfaced this — it spreads the existing
`extra` and so preserves waves by accident. Any client that does not is
exposed.
6 tests, including one pinning the ordering and one that fails if either
organiser-owned `extra` field loses its guard. 126 pass.
"Capacity is always required, there is no unlimited" was settled on #34
and enforced in #62 — but as a single number on the event. Since v1.6.8
capacity is per-wave and `event.amount_tickets` is a derived roll-up
(`sync_event_ticket_waves`), so the rule had nothing holding it up at
the level organisers actually set: the wave form's capacity input had no
minimum, and nothing on the backend checked one at all.
A zero-capacity wave can never be active — `get_active_ticket_waves`
requires `amount_tickets > 0` — so it is the wave-level form of exactly
what #62 removed: an event that looks on sale but refuses every
purchase, on the card and at checkout both.
`_validate_wave_capacity` now runs on create and update. Two details
that matter:
- it checks `ensure_ticket_waves(data)` rather than the raw list, so an
event submitted with no waves is checked through the primary wave it
is about to be given, not vacuously passed
- on an edit it only checks waves NEW to the event. Selling out is the
legitimate route to zero, and rejecting it would make a sold-out event
uneditable — including the legacy zero-capacity rows this rule exists
to let organisers fix
Frontend: the wave dialog's capacity input gains the `min="1"` and hint
the event-level field already had, and a new wave opens at 1 rather than
0. That default uses `??`, not `||` — a sold-out wave holds 0 and must
keep showing it instead of silently regaining stock when saved.
Also drops the stale `0 = unlimited / not ticketed` contract still
documented on `CreateEvent.amount_tickets`, which has not been true
since #62.
6 new tests; 120 pass. ruff, black, prettier clean; mypy error set
unchanged from baseline.
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.
Omitting the tag used to mean "unlimited capacity". Nothing else in the
codebase agreed: `api_get_event` and `api_ticket_create` both treat
`amount_tickets < 1` as sold out. So a zero-capacity event advertised
"Unlimited tickets" on the card while the detail page and the purchase
both returned 410.
Observed on aio-demo — three approved, listed, free events in that
state. For `PKBVuusKikfJFU4PYGtBTW`:
relay tickets_available absent -> webapp renders "Unlimited"
GET event 410 "Event is sold out."
POST ticket 410 "Event is sold out."
The admin form was advertising it too (`min="0"`, `hint="0 = unlimited"`),
so organizers were being invited into the broken state.
Now the tag is always emitted and zero reads as sold out, which is what
every other part of the system already believed. Clients that must handle
an absent tag — a NIP-52 event from another publisher — are unaffected,
since we simply never omit it.
Scoped to removing the contradiction. Making capacity a *required* field
is the other half of #34 and is blocked on the webapp: its create dialog
uses a falsy check (`if (formValues.amount_tickets)`), so a 0 omits the
field entirely and a server-side `ge=1` would 422 it.
Refs #34
lint.yml / Merge pull request 'fix(tickets): amount_tickets is the remaining count, stop subtracting sold' (#59) from fix/amount-tickets-remaining into main (push) Failing after 0s
`set_ticket_paid` decrements `amount_tickets` on every sale and
increments `sold`, so the two describe the same tickets. Subtracting
one from the other in `api_ticket_create` removed each sale twice:
remaining = amount_tickets - sold
That under-reported availability to buyers, and because
`remaining + sold` is the original capacity, `sold >= amount_tickets`
first becomes true at the halfway point — so every event locked itself
as sold out once half its seats had gone, with the rest still unsold.
Measured on aio-demo before the fix: 16 of 24 live events affected,
3 of them already refusing sales while stock remained. "Tech Meetup"
had 9 tickets left and offered -2.
The arithmetic was ours, introduced with the multi-quantity purchase
feature; upstream has no equivalent because it sells one ticket per
request and reads `amount_tickets` as remaining everywhere. So this
deletes the divergence and restores upstream's own guard plus the one
line `quantity` needs, which should also make the v1.6.8 rebase (#33)
a little easier rather than harder.
Tests walk a 50-seat event to capacity one sale at a time and pin the
boundary: sold=49 passes even unfixed, sold=50 is where it used to
lock. Six of the ten fail without the change.
Scoped deliberately to the double-subtraction. The rest of #34 — making
capacity a required field, dropping the `0 = unlimited` reading, and the
organizer form that prefills remaining as though it were capacity —
waits for #33, since ticket waves change the shape of that fix.
Refs #34
`publish_nostr_event` returned as soon as the EVENT was on the send
queue, and the publisher logged "Published" on the next line. Queueing
is not delivery: nostrclient drops an EVENT outright when no relay is
connected, answering `OK false "error: no relays connected"`. We threw
that reply away.
On cfaun this cost a completed repair. The #55 sweep republished a
14-day-stale calendar event 21 seconds before nostrclient had finished
connecting to its relay, got `OK false`, logged `Published`, reported
`1/1 recovered` and cleared `nostr_publish_pending` — leaving the count
stale, the row unflagged and the log asserting success. It took a
manual re-arm of the flag to finish the job.
So the flag's contract was never true: it claimed to clear only on a
confirmed success but cleared on a confirmed enqueue.
`publish_nostr_event` now registers a future per event id, awaits the
`OK`, and returns whether it was accepted. `publish_event_to_nostr`
returns None when unconfirmed, which keeps the row flagged so the sweep
retries rather than recording a delivery that never happened.
Correlation lives in `get_event`, the one place relay messages cross
from the websocket thread into the event loop — no cross-thread future
juggling. OK frames are consumed there rather than forwarded; the sync
loop never handled them. A disconnect settles every in-flight publish
immediately instead of making callers wait out the timeout.
On latency: the timeout is not the common cost. A disconnected relay is
rejected by nostrclient's router in milliseconds (230ms measured on
aio-demo), so the 12s budget only applies when relays are connected but
silent, which nostrclient itself bounds at 10s. `set_ticket_paid` runs
on the invoice-listener task, so that narrow case does stall the loop;
if it ever matters, the remedy is to stop awaiting on the sale path
while leaving the flag set — the sweep already guarantees eventual
delivery — not to go back to reporting unverified success.
Closes#56
Inventory reaches clients only through the republished calendar event,
and until now a publish that failed or was skipped left no durable
trace — only a log line, if that. Twice the drift was caught by a human
reading a wrong number on a public page (#35 on aio-demo, #51 on cfaun,
where an event's relay copy sat 14 days behind the DB).
Adds `events.nostr_publish_pending`, set before every attempt and
cleared only on a confirmed success. Ordering it that way is what makes
"the attempt was never made" — no signer resolved, no NostrClient, the
process died mid-flight — as discoverable as "the attempt raised". Both
shapes have now been observed in production; only the second one was
ever visible.
`set_ticket_paid` raises the flag inside its own update so the counters
and "the relay doesn't know about them yet" commit atomically, and the
sale path pays no extra write.
`publish_or_delete_nostr_event` now returns a bool so callers can
branch. The flag, not the return value, is the durable record — the
existing call sites stay correct ignoring it.
Publish failures move from WARNING to ERROR: the published ticket count
has stopped tracking reality, which is not routine journal noise.
Refs #35
Promo handling was inherited from upstream unchanged and had four gaps
the webapp was about to put in front of buyers:
- `active` was decorative: purchase never read it, so a deactivated code
kept discounting. Now rejected with "Promo code is not active."
- No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with
`used_count` DERIVED from paid tickets carrying the code in
`extra.applied_promo_code` — each ticket of a multi-ticket purchase
consumes one use (upstream v2 counts one per basket; documented).
Paid-only counting so an abandoned Stripe session can't lock out the
last uses for the 24 h unpaid-row lifetime; bounded overshoot under
concurrency accepted.
- Every code was readable by anyone: `PublicEvent.extra` was the full
`EventExtra` and `/events/public` returned the untrimmed `Event`
(wallet id included). `EventExtraBase` / `PublicEventExtra` project
them out; `/public` now goes through `PublicEvent`. Organizer and admin
listings keep the full model, now hydrated with `used_count`.
- No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as
upstream v2; `quantity` replaces v2's `items` since this fork has no
ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory:
bad codes are simply absent from `discounts_applied`; purchase still
hard-fails them with distinct details.
All pricing (validate, invoice, Stripe amount) goes through one pure
`basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so
the preview equals the charge. Stripe metadata carries `promo_code`; the
organizer stats rows carry `applied_promo_code`.
`api_event_update` keeps stored codes when the request omits
`extra.promo_codes` (explicit `[]` still clears): now that public
records don't carry them, a client round-tripping one would otherwise
wipe the organizer's codes on every edit.
Closes#32
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
The v1.6.1-aio.9 mail still scored 8.4/10 on mail-tester: the remaining
deduction was HTML_IMAGE_ONLY (1.8) — an HTML part whose only content of
note is a remote <img>. Remote images are also blocked by default in most
clients until the reader opts in, and a bare QR saved from that mail says
nothing about what it opens.
- New `qr.py` module (QR + logo helpers moved out of views_api) with
`render_ticket_card`: site title, event name, when/where, the branded
QR, name on ticket, ticket id and the door instruction, laid out with
the bundled DejaVu Sans; `format_event_when` gives "Fri 19 Feb 2027,
16:00 - 20:00"; filenames are `ticket-<event-slug>-<id8>.png`.
- `GET /events/api/v1/ticket-card/{ticket_id}` serves the same PNG
(anonymous, like the QR endpoint); the email's "Ticket image" link
now points there.
- The ticket email becomes multipart/mixed: text + HTML alternatives
(URLs as links, no <img>) plus the card as a PNG attachment, which
clients show inline at the end of the message and which works offline
at the door.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
A tester's ticket email landed in spam. A mail-tester run against demo
scored 8.3/10 with SPF, DKIM and DMARC all passing through the VPS relay,
so the deductions were all in the message: MISSING_DATE (1.4),
HTML_IMAGE_ONLY_04 (0.3), MISSING_MID (0.1) — and Gmail/Outlook weigh a
missing Date/Message-ID as "machine-generated" far more than that.
- `build_ticket_email` sets Date, a Message-ID under the sender domain,
and a From display name from `lnbits_site_title`.
- The body now carries the event name, dates, location, name on ticket,
ticket id and the door instruction, so the HTML part is no longer a
QR with a handful of words.
Upstream candidate: lnbits core `send_email` has the same omissions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
`_resolve_frontend_root` honours `CreateTicket.frontend_url` when its
origin is one of LNBITS_CORS_ALLOWED_ORIGINS, the LNbits base URL or
LNBITS_CUSTOM_FRONTEND_URL (400 otherwise — a silent fallback would send
the buyer to the wrong app), and falls back to request.base_url as before.
Under that root the fiat path now parameterises the hosted checkout via
`extra["checkout"]` (lnbits StripeCheckoutOptions): success_url
`/events/{id}?checkout=success&tickets=<ids>`, cancel_url
`/events/{id}?checkout=cancelled`, customer_email, an event-named line
item and event_id/quantity/ticket_ids metadata. Ticket ids are minted
before the invoice so the success URL can carry them (the payment_hash
only exists afterwards). ticket_base_url on the rows uses the same root,
so the emailed link lands in the webapp when the webapp was the client.
Purchases are gated on `effective_payment_methods(event)` (checked after
the free-ticket short-circuit, which charges nothing on any rail).
New anonymous `GET /events/api/v1/qr/{ticket_id}` returns a PNG of
`ticket://<id>` — port of upstream v1.6.8's endpoint without ticket-image
compositing — built at error-correction H with the instance QR logo
(`lnbits_qr_logo`) pasted in the centre, matching the client-side QRs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
`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
NIP-52 calendar events (31922/31923) are replaceable and republished
whenever inventory changes (a ticket sells). build_nip52_event stamped
created_at=int(time.time()); relays only push a replacement to OPEN
subscriptions when created_at is strictly newer, so two republishes in
the same wall-clock second tie and the second is silently dropped for
live subscribers — clients' "tickets remaining" badge stalls until a
reload. Same root cause as the webapp fix (aiolabs/webapp#122).
- Add monotonic_created_at() in nostr_timestamp.py = max(now, last+1),
mirroring the webapp helper + docs/nostr-patterns/replaceable-events.md.
- Anchor it on the already-persisted Event.nostr_event_created_at
(set after each publish in nostr_hooks.py). The kind-5 delete event is
not replaceable, so it keeps plain int(time.time()).
- Unit tests mirror the webapp's timestamp suite.
Concurrent same-second sales reading the same stored anchor can still
collide; full hardening (row-level lock) is noted as follow-up in #26.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>