"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.
v1.6.8 sells tickets on-chain through SatsPay charges — a per-purchase
watchonly address, a hosted charge page, and a webhook back into the
extension. We are not adopting that: on-chain goes through the fork's
native LndRestWallet support once core can mint purpose-bound receiving
addresses (aiolabs/lnbits#53). This is the parity note on #41, applied
as its own commit so the merge it follows stays a faithful diff of what
upstream shipped.
Removed:
- services: the five SatsPay/watchonly HTTP clients (and with them the
only uses of httpx and typing.Any in that module)
- views_api: _get_watchonly_status, GET /events/onchain/status, the
SatsPay charge branch of api_ticket_create, POST
/tickets/{id}/satspay-webhook, PUT /tickets/{hash}/onchain-confirm
- models: EventExtra.onchain_{enabled,wallet_id,zeroconf,fasttrack},
TicketExtra.satspay_charge_id, TicketPaymentRequest.satspay_charge_url
- frontend: the organiser's "Onchain payments" panel and its watchonly
wallet picker, the per-ticket confirm-onchain button, the SatsPay
charge-page redirect, and the wallet-status fetch behind them
`onchain` is no longer an accepted payment_method — there is no rail to
fulfil it until #41 lands, so accepting it could only fail later.
Kept as vocabulary for #41, in upstream's field names so it needs no
migration: TicketExtra.onchain / onchain_address and
TicketPaymentRequest.onchain_amount_sat.
35 routes register with no duplicates; ruff, black, prettier clean; 97
tests pass; 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.
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
`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
Free events (price_per_ticket == 0) tried to mint a 0-amount Lightning
invoice via create_payment_request — an invoice that can't settle, and
which the invoice listener would never mark paid, so the ticket never
became scannable.
api_ticket_create now short-circuits when the final charge is 0 (a free
event or a 100%-off promo, computed after promo + quantity) before any
invoice / fiat-provider logic: _issue_free_tickets creates the N rows and
runs each through the existing set_ticket_paid — the same path
on_invoice_paid drives for a settled payment (flip paid, bump
sold/available under the per-event lock, republish the NIP-52 event) —
plus the ticket notification. The response carries a new
TicketPaymentRequest.paid=True with no payment_request so the client
skips the QR / payment-poll and goes straight to the ticket QRs.
No invoice means sats_paid=0, so free tickets are naturally skipped by
refund_tickets. All rows in a batch share one synthetic payment_hash —
the join key the poll / WebSocket / My-Tickets lookups use — mirroring
the paid multi-ticket path.
Self-service forfeit (#28), abuse/identity limits (#29) and
pay-what-you-want/donation tickets (#30) are tracked as follow-ups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Rebases the aio fork onto upstream v1.6.1 (4bf867e), pulling in:
- fiat checkout + email/Nostr DM ticket notifications (PR #50)
- currency-conversion fix (v1.5.0)
- custom notification subject/body (v1.6.0)
- resend-email button on the ticket list (PR #51)
Notable merges:
- views_api.api_event_update keeps the explicit-field-list gating from
the aio.4 security fix, with allow_fiat + fiat_currency added so an
owner editing a fiat-enabled event keeps the fiat config.
- models.PublicEvent now exposes both upstream's fiat fields and our
location / categories / status fields.
- migrations.py reverts to byte-identical to upstream v1.6.1 (no aio
entries); fork schema lives in migrations_fork.py (per aiolabs/lnbits#8).
- Lint reformatted with black + ruff to match upstream style.
Contributors entry adds `padreug` (aio fork maintainer).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
event_start_date / event_end_date now accept either YYYY-MM-DD (date-only)
or YYYY-MM-DDTHH:MM (ISO datetime). The NIP-52 publisher switches kind
on the "T" delimiter: kind 31922 (date-based, YYYY-MM-DD start/end) when
absent, kind 31923 (time-based, unix-timestamp start/end + day-granularity
D tags) when present. Delete events match the original publish kind.
Closing-date parsing accepts both formats. The LNbits admin form gains
optional HH:MM inputs alongside each date picker; they fold into the
wire-format string on submit and split back on edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Approved events are mirrored to Nostr as NIP-52 calendar events (kind
31922) signed by the wallet owner's pubkey, and incoming kind 31922/31923
events from subscribed relays are synced into the local DB so events
created on other LNbits instances or Nostr clients show up locally.
- m009 stores nostr_event_id + nostr_event_created_at on each event
(used for replaceable updates and NIP-09 deletes); m011 adds location
+ JSON-encoded categories list (NIP-52 location/`t` tags).
- models: Event/PublicEvent/CreateEvent gain location, categories,
nostr_event_id, nostr_event_created_at; parse_categories validator
decodes the JSON column on read.
- nostr/{event,nostr_client}.py: Schnorr signing, websocket relay client,
and a NostrEvent model (publish-only and subscribe variants).
- nostr_publisher.py: build/sign NIP-52 kind 31922 events and NIP-09
delete events; publish via the relay client.
- nostr_sync.py: subscribe to kinds 31922/31923, dedupe by nostr_event_id
/ d-tag, upsert Events; auto-approves discovered Nostr events since
they're already public.
- nostr_hooks.py: thin bridge that views_api handlers call to publish
or delete a NIP-52 event for a given local event. Lives in its own
module to keep `from . import nostr_client` out of the view layer
and avoid the views_api -> publisher import cycle.
- views_api: hooks publish_or_delete_nostr_event into create-on-approved,
update-when-already-published, cancel (delete), delete (delete), and
approve (publish).
- __init__.py: 3-task lifespan — wait_for_paid_invoices (upstream),
NostrClient bootstrap, and the NIP-52 sync loop. Module-level
nostr_client global is set by the bootstrap and read dynamically by
publish_or_delete_nostr_event so the import order works regardless of
whether nostrclient is up at startup.
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>
* escape name
* add email pydantic validation (API)
* format prettier
* don't allow slash on email also
* make regex const
* use string literals
* make get ticket a POST
* email regex
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>