Compare commits

...

53 commits

Author SHA1 Message Date
cadf30b704 chore(release): v1.6.8-aio.4
Some checks failed
lint.yml / chore(release): v1.6.8-aio.4 (push) Failing after 0s
- #68 ticket waves never rendered in the organiser table. The chip built
  its label inline with `LNbits.utils.formatCurrency(...)`, but Vue
  resolves template expressions against the component instance, where the
  `LNbits` global is not in scope. It threw "Cannot read properties of
  undefined (reading 'utils')" and the whole v-for rendered nothing, so
  every event showed an empty wave list however many waves it had.
  Organisers could not see or edit a wave from the LNbits UI at all.

The line came in with the v1.6.8 merge: it is upstream's, and the only
`LNbits.` reference in any template here — display.vue and ticket.vue
have none. `waveSummary` now builds the label in index.js, and trims the
time off wave dates for display.

Frontend only; no Python, no schema, no migration.

Verified in a browser against a two-wave event before tagging: both
chips render with formatted currency and no TypeError.
2026-10-03 21:06:55 +02:00
0e9cc76fef Merge pull request 'Ticket waves never rendered in the organiser table' (#68) from fix/wave-chip-lnbits-global into main
Some checks failed
lint.yml / Merge pull request 'Ticket waves never rendered in the organiser table' (#68) from fix/wave-chip-lnbits-global into main (push) Failing after 0s
Reviewed-on: #68
2026-10-03 19:05:55 +00:00
b4ca9fe65d fix(ui): ticket waves never rendered in the organiser table
Some checks failed
lint.yml / fix(ui): ticket waves never rendered in the organiser table (pull_request) Failing after 0s
The expanded event row showed "Ticket waves" with a + button and an
empty list, however many waves the event had. Reported from aio-demo and
reproduced in a fresh incognito window, so not a caching artifact.

The chip built its label inline with `LNbits.utils.formatCurrency(...)`.
Vue resolves template expressions against the component instance, where
the `LNbits` global is not in scope, so the expression threw

    TypeError: Cannot read properties of undefined (reading 'utils')

and the whole v-for rendered nothing. The promo-code chips beside it
were unaffected, which is what made the list look like a data problem —
the waves were always present in the response.

That line arrived with the v1.6.8 merge: it is upstream's, and upstream
is the only `LNbits.` reference in any template in this extension
(display.vue and ticket.vue have none). So the convention here was
already "format in a method", and the merge quietly broke it.

`waveSummary` now builds the label in index.js, where the global is in
scope. It also trims the day off wave dates for display — closing_date
defaults from event_end_date and can carry a time, which was rendering
as "2026-12-09T16:00:00+01:00" in the chip.

Verified in a browser against a two-wave event: both chips render as
"Primary wave - 2026-10-03 to 2026-11-02 - €30.00 - 50 tickets - 0 sold"
with no TypeError.
2026-10-03 20:53:27 +02:00
69c9a4f757 chore(release): v1.6.8-aio.3
Some checks failed
lint.yml / chore(release): v1.6.8-aio.3 (push) Failing after 0s
- #67 honour per-wave fiat when the organiser set an explicit rail list.
  `effective_payment_methods` returned `extra.payment_methods` before
  consulting the wave, so the `wave` argument added for #61 did nothing
  in the common case — the webapp always sets that list. The NIP-52 tag
  advertised `tickets_payment_methods: lightning,fiat` while omitting
  `tickets_allow_fiat`, and the checkout offered a card button that
  `api_ticket_create` then refused. Reported from aio-demo.

Eight lines in `models.py`; no schema change, no migration,
`migrations.py` still byte-identical to upstream v1.6.8.

The organiser-facing symptom is already gone on demo — aiolabs/webapp#179
makes the Card checkbox govern every wave, and re-saving the event
corrected all four. This is the backend half: it stops the published tag
contradicting itself when a wave genuinely cannot take fiat, which the
LNbits admin's per-wave toggles still allow.
2026-10-03 07:51:46 +02:00
33501dfc90 Merge pull request 'Honour per-wave fiat when the organiser set an explicit rail list' (#67) from fix/wave-aware-payment-methods into main
Some checks failed
lint.yml / Merge pull request 'Honour per-wave fiat when the organiser set an explicit rail list' (#67) from fix/wave-aware-payment-methods into main (push) Failing after 0s
Reviewed-on: #67
2026-10-03 05:50:43 +00:00
b55d6866d6 fix: honour per-wave fiat when the organiser set an explicit rail list
Some checks failed
lint.yml / fix: honour per-wave fiat when the organiser set an explicit rail list (pull_request) Failing after 0s
Reported from aio-demo: an event with fiat enabled, Card offered at
checkout, and the purchase refused with "Fiat payments are not enabled
for this ticket wave."

`effective_payment_methods` returned the organiser's explicit
`extra.payment_methods` list before ever consulting the wave:

    explicit = list(...)
    if explicit:
        return explicit          # <- the wave never got a look in

So the `wave` argument I added for #61 did nothing in the common case.
The webapp always sets `extra.payment_methods` from its payment-method
checkboxes, which means the explicit path is the normal one, not the
exception — three layers then disagreed:

- the NIP-52 tag advertised `tickets_payment_methods: lightning,fiat`
  while omitting `tickets_allow_fiat`, contradicting itself
- the checkout rendered a Card button
- `api_ticket_create`, the only wave-aware check, refused the purchase

Asking about a specific wave means asking what a buyer can actually use
for it, so a rail that wave cannot honour is now dropped. The
event-level question (no wave) still reports every rail the organiser
enabled — that is what `/republish-all` and the admin views want.

Fiat-only rails on a non-fiat wave now yield an empty list, which is
honest: nothing is purchasable from that wave.

5 tests, including both publisher shapes with an explicit list — the
case that actually bit, and which the #61 tests missed because their
fixtures left `payment_methods` empty. 133 pass.

Does not fix the data on events already created through the webapp:
their waves were saved with `allow_fiat` unset, so those waves genuinely
cannot take fiat until toggled. That is aiolabs/webapp's side.
2026-09-30 19:32:07 +02:00
fe40d2ab14 chore(release): v1.6.8-aio.2
Some checks failed
lint.yml / chore(release): v1.6.8-aio.2 (push) Failing after 0s
Two wave fixes found while checking whether the webapp had been updated
for v1.6.8.

- #65 keep ticket waves when a client edits an event without them. A
  client that rebuilt the `extra` envelope rather than round-tripping it
  destroyed every wave: the list arrived empty, a single primary wave was
  synthesized from the event-level `amount_tickets`, and a multi-wave
  event silently collapsed into one tier. `promo_codes` had carried the
  same guard since the v1.6.8 merge; `ticket_waves` is the same class of
  organiser state and now carries it too.
- #66 expose `ticket_waves` on public event responses. A buyer cannot
  choose a tier without its id, and nothing public carried one — the
  NIP-52 tags describe the active wave but name no id. Waves move to
  `EventExtraBase`, leaving promo codes as the only organiser-private
  field in `extra`. Buyers can now see upcoming tiers and their prices,
  which is what #61 recorded as the cost of the flat-tag decision.

No schema change; both are model/serialisation only.

Verified against bohm's dev LNbits before tagging: the exact PUT that
collapsed a two-wave event now leaves both intact with the roll-up
unchanged, the public endpoint carries the waves while still hiding
promo codes, and a webapp-shaped edit that writes through to the primary
wave takes effect (139 = 99 + 40) where it was previously discarded.

#66 is a prerequisite for aiolabs/webapp#176, which is merged to dev and
needs this deployed before its wave picker works for anyone but the
organiser.
2026-09-29 09:24:36 +02:00
26c0a5c429 Merge pull request 'Expose ticket waves on public event responses' (#66) from feat/public-ticket-waves into main
Some checks failed
lint.yml / Merge pull request 'Expose ticket waves on public event responses' (#66) from feat/public-ticket-waves into main (push) Failing after 0s
Reviewed-on: #66
2026-09-29 06:58:36 +00:00
e706b46003 Merge pull request 'Keep ticket waves when a client edits an event without them' (#65) from fix/preserve-ticket-waves-on-edit into main
Some checks failed
lint.yml / Merge pull request 'Keep ticket waves when a client edits an event without them' (#65) from fix/preserve-ticket-waves-on-edit into main (push) Failing after 0s
Reviewed-on: #65
2026-09-29 06:58:25 +00:00
209a895415 feat: expose ticket waves on public event responses
Some checks failed
lint.yml / feat: expose ticket waves on public event responses (pull_request) Failing after 0s
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.
2026-09-29 08:14:49 +02:00
064795c62a fix: keep ticket waves when a client edits an event without them
Some checks failed
lint.yml / fix: keep ticket waves when a client edits an event without them (pull_request) Failing after 0s
`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.
2026-09-29 08:12:09 +02:00
6bdebe4562 chore(release): v1.6.8-aio.1
Some checks failed
lint.yml / chore(release): v1.6.8-aio.1 (push) Failing after 0s
Rebase onto upstream v1.6.8, so the upstream segment moves and the aio
patch counter resets to 1.

- #63 merge upstream v1.6.8: ticket waves (per-wave price/currency/
  stock/fiat), paginated tickets, the organiser ticket-image template.
  Pricing and inventory now follow the wave the buyer selected rather
  than the primary-wave roll-up; npub checkout and DM delivery kept
  where upstream removed them; `_parse_date` accepts the ISO datetimes
  our closing dates carry.
- #63 drop the SatsPay/watchonly on-chain surface — on-chain will go
  through native LndRest once aiolabs/lnbits#53 lands (#41).
- #63 publish the active wave over Nostr, with `nostr_published_wave_id`
  (fork migration m004) so the reconciliation sweep republishes at wave
  boundaries (#61).
- #64 require a capacity on every ticket wave (#34, #62).

`migrations.py` stays byte-identical to upstream. v1.6.8 adds no
upstream migrations over v1.6.1 — waves live in `extra` JSON — so no
`dbversions` surgery is needed on existing installs; only the fork
namespace advances, `events_fork` 3 -> 4.
2026-09-29 00:04:21 +02:00
09c81c377b Merge pull request 'Require a capacity on every ticket wave' (#64) from fix/per-wave-capacity into main
Some checks failed
lint.yml / Merge pull request 'Require a capacity on every ticket wave' (#64) from fix/per-wave-capacity into main (push) Failing after 0s
Reviewed-on: #64
2026-09-28 22:03:03 +00:00
317a474816 Merge pull request 'Rebase onto upstream v1.6.8' (#63) from rebase/upstream-v1.6.8 into main
Some checks failed
lint.yml / Merge pull request 'Rebase onto upstream v1.6.8' (#63) from rebase/upstream-v1.6.8 into main (push) Failing after 0s
Reviewed-on: #63
2026-09-28 22:02:54 +00:00
ecd05b4168 fix: require a capacity on every ticket wave
Some checks failed
lint.yml / fix: require a capacity on every ticket wave (pull_request) Failing after 0s
"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.
2026-09-28 23:11:30 +02:00
15c2276e57 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.
2026-09-28 22:28:09 +02:00
d90a0f8322 refactor: drop the SatsPay/watchonly on-chain surface
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.
2026-09-28 22:21:55 +02:00
ae5affa44f Merge upstream v1.6.8 into the aio fork
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.
2026-09-28 22:09:18 +02:00
ab4175a89a chore(release): v1.6.1-aio.18
Some checks failed
lint.yml / chore(release): v1.6.1-aio.18 (push) Failing after 0s
- #62 `tickets_available` is always published, including zero. Omitting
  it used to mean "unlimited", which nothing else agreed with:
  api_get_event and api_ticket_create both read `amount_tickets < 1` as
  sold out, so a zero-capacity event advertised unlimited tickets on the
  card and returned 410 to every purchase. Three such events were live
  on aio-demo. The admin form no longer offers 0 either.

No schema change. Existing zero-capacity events are not migrated — we
cannot infer whether the organiser meant unlimited or forgot to set a
number — and they will read as sold out once republished. The #55 sweep
will not flag them on its own, so /republish-all is the way to refresh.
2026-09-27 23:12:12 +02:00
e97b9b2134 Merge pull request 'fix(nostr): always publish tickets_available, zero is not unlimited' (#62) from fix/zero-capacity-is-not-unlimited into main
Some checks failed
lint.yml / Merge pull request 'fix(nostr): always publish tickets_available, zero is not unlimited' (#62) from fix/zero-capacity-is-not-unlimited into main (push) Failing after 0s
Reviewed-on: #62
2026-09-27 21:10:34 +00:00
adfd4529f5 fix(nostr): always publish tickets_available, zero is not unlimited
Some checks failed
lint.yml / fix(nostr): always publish tickets_available, zero is not unlimited (pull_request) Failing after 0s
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
2026-09-27 23:03:54 +02:00
68a11bb50e chore(release): v1.6.1-aio.17
Some checks failed
lint.yml / chore(release): v1.6.1-aio.17 (push) Failing after 0s
Ticket availability and publish delivery.

- #58 publishes are confirmed against the relay's NIP-01 `OK` instead of
  the send queue. A publish that never reaches a relay now leaves the row
  flagged for the sweep rather than reporting success and clearing it —
  which had silently reverted a completed repair on cfaun. Verified end
  to end on aio-demo: delivery confirmed, the no-relay failure caught
  with the relay's own diagnostic in the log, and the retry recovering
  once the relay returned.
- #59 `amount_tickets` is the remaining count; `api_ticket_create` no
  longer subtracts `sold` from it. Every event was locking itself as sold
  out at half capacity. 16 of 24 live events on aio-demo were affected,
  3 already refusing sales with stock remaining.

No schema change. Affected events start selling again on upgrade; worth
re-running an availability check per host afterwards.
2026-09-27 22:27:40 +02:00
17ec84c59d Merge pull request 'fix(tickets): amount_tickets is the remaining count, stop subtracting sold' (#59) from fix/amount-tickets-remaining into main
Some checks failed
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
Reviewed-on: #59
2026-09-27 20:27:09 +00:00
13eb549066 Merge pull request 'feat(nostr): confirm publishes against the relay&#39;s OK' (#58) from feat/confirm-publish-with-relay-ok into main
Some checks failed
lint.yml / Merge pull request 'feat(nostr): confirm publishes against the relay&#39;s OK' (#58) from feat/confirm-publish-with-relay-ok into main (push) Failing after 0s
Reviewed-on: #58
2026-09-27 20:26:57 +00:00
ad8aa2cf00 fix(tickets): amount_tickets is the remaining count, stop subtracting sold
Some checks failed
lint.yml / fix(tickets): amount_tickets is the remaining count, stop subtracting sold (pull_request) 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
2026-09-27 22:25:01 +02:00
cc730256ab feat(nostr): confirm publishes against the relay's OK
Some checks failed
lint.yml / feat(nostr): confirm publishes against the relay's OK (pull_request) Failing after 0s
`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
2026-09-27 22:11:21 +02:00
cac7d16ece chore(release): v1.6.1-aio.16
Some checks failed
lint.yml / chore(release): v1.6.1-aio.16 (push) Failing after 0s
Nostr publish reliability.

- #54 the two paths that skip a NIP-52 publish now log at WARNING
  instead of silently (bare return / debug); publish failures moved to
  ERROR
- #55 `events.nostr_publish_pending` marks a row from before each
  publish attempt until a confirmed success, and a 5-minute sweep
  republishes whatever is still flagged, so drift recovers on its own
  instead of waiting for an operator who knows to run /republish-all
- #55 the NostrClient send loop holds and retries a dequeued req
  (bounded at 3) rather than dropping it

Adds migration m003 (events.nostr_publish_pending). Verified on bohm:
events_fork 1 -> 3, column present, event created and published to a
relay with the flag clearing on success.
2026-09-27 09:27:40 +02:00
114f3406a6 Merge pull request 'feat(nostr): make publish drift queryable and self-healing' (#55) from feat/nostr-publish-reconciliation into main
Some checks failed
lint.yml / Merge pull request 'feat(nostr): make publish drift queryable and self-healing' (#55) from feat/nostr-publish-reconciliation into main (push) Failing after 0s
Reviewed-on: #55
2026-09-26 21:47:40 +00:00
dc2a296bad fix(nostr): retry a dequeued req instead of dropping it
Some checks failed
lint.yml / fix(nostr): retry a dequeued req instead of dropping it (pull_request) Failing after 0s
`run_forever` took a req off the queue and then sent it; if the send
raised, the req was already gone and the publish was lost outright,
with the caller long since told it succeeded (the queue put returns
immediately, and the publisher logs "Published" straight after).

Hold the req across the reconnect and retry, bounded at three attempts
so one unsendable message can't wedge every later publish behind it.

This narrows but does not close the gap: a send is still confirmed at
the queue, not by the relay's OK, so a half-dead socket can accept
bytes that never arrive. Closing that needs OK handling in
publish_nostr_event.

Refs #35
2026-09-26 23:45:47 +02:00
643322131e feat(nostr): sweep republishes events flagged as pending
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
2026-09-26 23:45:47 +02:00
5d52a231d3 feat(nostr): flag events whose NIP-52 publish didn't land
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
2026-09-26 23:45:47 +02:00
d2b8550d7f Merge pull request 'fix(nostr): log publish skips at WARNING instead of silently' (#54) from fix/publish-skip-logging into main
Some checks failed
lint.yml / Merge pull request 'fix(nostr): log publish skips at WARNING instead of silently' (#54) from fix/publish-skip-logging into main (push) Failing after 0s
Reviewed-on: #54
2026-09-26 21:37:03 +00:00
165787d134 fix(nostr): log publish skips at WARNING instead of silently
Some checks failed
lint.yml / fix(nostr): log publish skips at WARNING instead of silently (pull_request) Failing after 0s
Both paths that decline to publish a NIP-52 calendar event were
invisible at the INFO level instances actually run at: the
`signer is None` branch returned bare with no log at any level, and
the missing-client branch logged at debug.

A skipped publish leaves the relay serving whatever inventory it last
saw, so the public ticket count stops tracking the DB with nothing to
indicate it. That has now been found twice, both times only because a
human noticed a wrong number on a public page — #35 on aio-demo, and
on cfaun where an event drifted for 14 days and produced no log line
at all, success or failure.

Both messages name the event id and whether it was a publish or a
delete, so a skip can be tied to a specific event without correlating
timestamps by hand.

Refs #51
2026-09-26 23:34:47 +02:00
7b66588d18 chore(release): v1.6.1-aio.15
Some checks failed
lint.yml / chore(release): v1.6.1-aio.15 (push) Failing after 0s
Since v1.6.1-aio.14: promo codes enforced — active flag, max_uses with
derived used_count, codes hidden from public event records, anonymous
POST /api/v1/promo/validate/{event_id} preview, single basket_totals
pricing path, Stripe metadata.promo_code (#45).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:55:40 +02:00
50c40439b7 Merge pull request 'feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public (v1.6.1-aio.12)' (#45) from feat/promo-codes into main
Some checks failed
lint.yml / Merge pull request 'feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public (v1.6.1-aio.12)' (#45) from feat/promo-codes into main (push) Failing after 0s
Reviewed-on: #45
2026-09-13 16:54:51 +00:00
93cc95fe18 docs: promo-code contract
Some checks failed
lint.yml / docs: promo-code contract (pull_request) Failing after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-13 18:43:48 +02:00
07519bec1b feat(admin): max uses column + used count in the promo editor
The Quasar editor gains a "Max uses" input per code (blank = unlimited,
normalised to null on save) and shows "Used n / max" from the derived
count. The public buy page's Clear button now also clears the promo field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-13 18:43:34 +02:00
8602bd71e3 feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public
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
2026-09-13 18:43:34 +02:00
4c3b1bca31 chore(release): v1.6.1-aio.14
Some checks failed
lint.yml / chore(release): v1.6.1-aio.14 (push) Failing after 0s
Since v1.6.1-aio.13: "Email sent" column in the admin tickets table (#48).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:42:39 +02:00
c230e61031 Merge pull request 'feat(admin): "Email sent" column in the tickets table' (#48) from feat/ticket-email-sent-column into main
Some checks failed
lint.yml / Merge pull request 'feat(admin): "Email sent" column in the tickets table' (#48) from feat/ticket-email-sent-column into main (push) Failing after 0s
Reviewed-on: #48
2026-09-13 16:41:34 +00:00
87c74ce13f feat(admin): "Email sent" column in the tickets table
Some checks failed
lint.yml / feat(admin): "Email sent" column in the tickets table (pull_request) Failing after 0s
Organizers had no way to see whether a ticket email went out short of
reading the Postfix journal. Derive a column from the flag the mailer
already sets on each ticket (`extra.email_notification_sent`):
"✓ sent" / "not sent" (paid, no delivery yet) / "unpaid" / "no email".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:39:52 +02:00
f835766935 feat(admin): event form parity with the webapp (1.6.1-aio.13)
Some checks failed
lint.yml / feat(admin): event form parity with the webapp (1.6.1-aio.13) (pull_request) Failing after 0s
lint.yml / feat(admin): event form parity with the webapp (1.6.1-aio.13) (push) Failing after 0s
The LNbits admin form lagged the webapp's CreateEventDialog:

- Payment methods never rendered. c2d9a96 wired the template to
  `paymentMethodOptions` / `acceptsFiat` but never defined them, so the
  q-option-group got `options=undefined` and the fiat-currency select
  was gated on `undefined`. Rails are now two q-checkboxes; Card is
  disabled with an explanatory tooltip when `g.user.fiat_providers` is
  empty (same rule as the webapp) and names the providers otherwise.
- Location (NIP-52 `location` tag) and Categories (NIP-52 `t` tags,
  same 25-item list as the webapp's category.ts) were missing from the
  form even though the model, CRUD and publisher already carry them.
- Datetimes are stamped with the browser's UTC offset on submit, as the
  webapp does; `_to_unix` treats naive values as UTC, so 18:00 CEST
  entered here went out on Nostr as 18:00 UTC. Table columns render
  "YYYY-MM-DD HH:MM" instead of the raw ISO string.
- Validation: title + start date required, end >= start on the folded
  date+time, fiat currency required when a sat-priced event accepts
  card. Create is enabled once wallet + title + start are set; info,
  closing date, tickets and price were all effectively required before
  because the disable check compared undefined fields to null.
- Labels follow the payment-rails vocabulary: "Unit" -> "Price
  currency", "Fiat checkout currency" -> "Fiat currency"; ticket
  closing date and end date explain their defaults.
- A fiat-priced event mirrors `fiat_currency = currency` on save so the
  payload and the `tickets_fiat_currency` tag stay coherent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b1bDExMX7W3a47wcgFUjb
2026-09-10 13:43:53 +02:00
f11e84d967 Merge pull request 'feat: attach a self-describing ticket card to the email (v1.6.1-aio.10)' (#43) from feat/ticket-card-attachment into main
Some checks failed
lint.yml / Merge pull request 'feat: attach a self-describing ticket card to the email (v1.6.1-aio.10)' (#43) from feat/ticket-card-attachment into main (push) Failing after 0s
Reviewed-on: #43
2026-09-08 14:53:05 +00:00
2f8a602bbd feat: attach a self-describing ticket card to the email (1.6.1-aio.10)
Some checks failed
lint.yml / feat: attach a self-describing ticket card to the email (1.6.1-aio.10) (pull_request) Failing after 0s
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
2026-09-08 16:40:25 +02:00
defe6d5b00 chore: bundle DejaVu Sans for server-rendered ticket cards
Pillow's built-in default font has no accented glyphs, so any French
event name ("Château") renders as tofu. DejaVu Sans (Bitstream Vera
licence, included) covers Latin fully and is what the ticket card uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-08 16:40:23 +02:00
3a8e0ff591 fix: Date/Message-ID/From-name on ticket emails, richer body (1.6.1-aio.9)
Some checks failed
lint.yml / fix: Date/Message-ID/From-name on ticket emails, richer body (1.6.1-aio.9) (pull_request) Failing after 0s
lint.yml / fix: Date/Message-ID/From-name on ticket emails, richer body (1.6.1-aio.9) (push) Failing after 0s
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
2026-09-08 16:19:38 +02:00
dni ⚡
f745370c33
fix: issue when calling internal webhook from extern (#57)
- query from localhost and port, important if behind a proxy without
loopback
2026-06-16 10:04:03 +02:00
dni ⚡
99b43ff851
fix: use satspay for onchain payments (#56)
* use satspay
* safeguard satspay
* add fasttrack and 0conf
2026-06-05 12:12:08 +02:00
dni ⚡
da16b209cd
fix: onchain listeners (#54)
* fix: onchain listeners
2026-06-04 11:08:59 +02:00
dni ⚡
e0ea0e30e7
feat: onchain payments (#53)
* feat: onchain payments
2026-06-03 10:51:15 +02:00
Arc
35c20eda72 fix: another hot one 2026-05-22 20:36:30 +01:00
Arc
8c1538f5fc fix: no submit bug 2026-05-22 20:30:24 +01:00
Arc
777c107c63
feat: UI fixes, ticket waves, ticket image (#52)
* email input fixes

* promo code ui fix

* ticket waves

* works, but horrible slop

* slightly cleaner

* filter most recent

* make

* pyright
2026-05-22 20:18:39 +01:00
38 changed files with 5126 additions and 470 deletions

View file

@ -62,10 +62,20 @@ Events includes a shareable ticket scanner, which can be used to register attend
- **Stripe session.** The buyer's email is passed as `customer_email` - **Stripe session.** The buyer's email is passed as `customer_email`
(prefilled and locked on the hosted page); the line item is named after the (prefilled and locked on the hosted page); the line item is named after the
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata. event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
- **Email.** Multipart text + HTML with the ticket QR embedded from - **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`,
`GET /events/api/v1/qr/{ticket_id}` (PNG, branded with the instance QR logo). `max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are
`POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a never part of public responses. Buyers preview a code with
`TicketResendResult` with per-channel outcome. `POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped
`BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket
of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct
`detail`. Updates that omit `extra.promo_codes` keep the stored list.
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
attached — a self-describing PNG (site, event, when, where, QR with the
instance logo, name on ticket, ticket id) also served at
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email`
returns a `TicketResendResult` with per-channel outcome.
## Powered by LNbits ## Powered by LNbits

View file

@ -6,13 +6,19 @@ from loguru import logger
from .crud import db from .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_paid_invoices
from .views import events_generic_router from .views import events_generic_router
from .views_api import events_api_router, qr_api_router, tickets_api_router from .views_api import (
events_api_router,
promo_api_router,
qr_api_router,
tickets_api_router,
)
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router) events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router) events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router) events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router) events_ext.include_router(qr_api_router)
events_ext.include_router(promo_api_router)
events_static_files = [ events_static_files = [
{ {
@ -28,6 +34,15 @@ scheduled_tasks: list[asyncio.Task] = []
# from nostr_hooks.publish_or_delete_nostr_event. # from nostr_hooks.publish_or_delete_nostr_event.
nostr_client = None nostr_client = None
# Reconciliation sweep for NIP-52 publishes that never reached a relay
# (aiolabs/events#35). Five minutes is well under the window in which a
# stale ticket count matters to a buyer, and the query costs nothing
# when there is no drift — the normal case returns zero rows.
REPUBLISH_SWEEP_INTERVAL = 300
# Long enough for _start_nostr_client's own 10s wait plus the relay
# handshake, so the first pass isn't guaranteed to fail on a cold boot.
REPUBLISH_SWEEP_FIRST_DELAY = 60
def events_stop(): def events_stop():
for task in scheduled_tasks: for task in scheduled_tasks:
@ -111,5 +126,55 @@ def events_start():
task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events) task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
scheduled_tasks.append(task3) scheduled_tasks.append(task3)
async def _republish_pending_sweep():
"""Retry NIP-52 publishes that never landed.
Inventory reaches clients only through the republished calendar
event, and a publish can fail (signer outage) or be skipped
entirely (no signer, no NostrClient) without anything noticing.
Both leave `nostr_publish_pending` set, so this sweep retries
from the DB rather than from an in-memory queue — it survives a
restart, which the previous behaviour did not.
Quiet by design: on a healthy instance the query returns nothing
and this logs nothing. It only speaks up when there is drift.
"""
from .crud import flag_wave_transitions, get_events_pending_republish
from .nostr_hooks import publish_or_delete_nostr_event
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
while True:
try:
# Wave boundaries are time-driven, so nothing else flags
# them. Done here rather than on a timer of its own: the
# boundary is day-granular, so one sweep interval of
# staleness is immaterial (aiolabs/events#61).
moved = await flag_wave_transitions()
if moved:
logger.info(
f"[EVENTS] Republish sweep: {moved} event(s) changed "
f"ticket wave"
)
pending = await get_events_pending_republish()
if pending:
total = len(pending)
logger.info(f"[EVENTS] Republish sweep: {total} event(s) pending")
recovered = 0
for event in pending:
take_down = event.canceled or event.status != "approved"
if await publish_or_delete_nostr_event(event, delete=take_down):
recovered += 1
logger.info(
f"[EVENTS] Republish sweep: {recovered}/{total} recovered"
)
except Exception as exc:
logger.error(f"[EVENTS] Republish sweep failed: {exc}")
await asyncio.sleep(REPUBLISH_SWEEP_INTERVAL)
task4 = create_permanent_unique_task(
"ext_events_republish_sweep", _republish_pending_sweep
)
scheduled_tasks.append(task4)
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.6.1-aio.8", "version": "1.6.8-aio.4",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",

143
crud.py
View file

@ -1,10 +1,20 @@
import json import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import cast
from lnbits.db import Database from lnbits.db import Database, Filters, Page
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra from .models import (
CreateEvent,
Event,
EventsSettings,
Ticket,
TicketExtra,
TicketFilters,
advertised_wave_key,
sync_event_ticket_waves,
)
db = Database("ext_events") db = Database("ext_events")
@ -155,6 +165,31 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
return [Ticket(**_parse_ticket_row(row)) for row in rows] return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_paginated(
wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None
) -> Page[Ticket]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
wallet_where = []
values = {}
for idx, wallet_id in enumerate(wallet_ids):
key = f"wallet_id_{idx}"
wallet_where.append(f":{key}")
values[key] = wallet_id
where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"]
return await db.fetch_page(
"SELECT * FROM events.ticket",
where=where,
values=values,
filters=filters,
model=Ticket,
table_name="events.ticket",
)
async def delete_ticket(payment_hash: str) -> None: async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -184,44 +219,55 @@ async def create_event(data: CreateEvent) -> Event:
if not data.closing_date: if not data.closing_date:
data.closing_date = data.event_end_date data.closing_date = data.event_end_date
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
event = cast(Event, sync_event_ticket_waves(event))
await db.insert("events.events", event) await db.insert("events.events", event)
return event return event
async def update_event(event: Event) -> Event: async def update_event(event: Event) -> Event:
event = cast(Event, sync_event_ticket_waves(event))
await db.update("events.events", event) await db.update("events.events", event)
return event return event
async def get_event(event_id: str) -> Event | None: async def get_event(event_id: str) -> Event | None:
return await db.fetchone( event = await db.fetchone(
"SELECT * FROM events.events WHERE id = :id", "SELECT * FROM events.events WHERE id = :id",
{"id": event_id}, {"id": event_id},
Event, Event,
) )
return cast(Event, sync_event_ticket_waves(event)) if event else None
async def get_events(wallet_ids: str | list[str]) -> list[Event]: async def get_events(wallet_ids: str | list[str]) -> list[Event]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall( events = await db.fetchall(
f"SELECT * FROM events.events WHERE wallet IN ({q})", f"SELECT * FROM events.events WHERE wallet IN ({q})",
model=Event, model=Event,
) )
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
async def get_all_events() -> list[Event]: async def get_all_events() -> list[Event]:
"""All events, no wallet filter. Admin-only callers.""" """All events, no wallet filter. Admin-only callers."""
return await db.fetchall( events = await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC", "SELECT * FROM events.events ORDER BY time DESC",
model=Event, model=Event,
) )
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_public_events() -> list[Event]: async def get_public_events() -> list[Event]:
"""Approved, non-canceled events for the public listing.""" """Approved, non-canceled events for the public listing."""
return await db.fetchall( events = await db.fetchall(
""" """
SELECT * FROM events.events SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE WHERE status = 'approved' AND canceled = FALSE
@ -229,14 +275,97 @@ async def get_public_events() -> list[Event]:
""", """,
model=Event, model=Event,
) )
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_pending_events() -> list[Event]: async def get_pending_events() -> list[Event]:
"""Proposed events awaiting admin approval.""" """Proposed events awaiting admin approval."""
return await db.fetchall( events = await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event, model=Event,
) )
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def flag_wave_transitions() -> int:
"""Flag events whose advertised ticket wave has moved on.
Every republish this extension performs is *sale*-driven. A wave
boundary is a *date* boundary, so when early bird closes at midnight
nothing fires and the relay keeps serving the closed wave's price until
the next ticket happens to sell (aiolabs/events#61).
Rather than add a scheduler and a second publish path, this compares the
wave a row would advertise now against the one its last successful
publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending`
on a mismatch — handing the work to the existing reconciliation sweep,
which already retries, survives restarts and logs.
Rows with NULL `nostr_published_wave_id` are skipped: that means "never
published, or published before the column existed", which is no evidence
of drift. Flagging them would republish the whole table on first boot
after the upgrade.
Returns the number of rows newly flagged.
"""
events = await db.fetchall(
"""
SELECT * FROM events.events
WHERE nostr_published_wave_id IS NOT NULL
AND nostr_publish_pending = FALSE
AND canceled = FALSE
AND status = 'approved'
""",
model=Event,
)
flagged = 0
for event in events:
event = cast(Event, sync_event_ticket_waves(event))
if advertised_wave_key(event) == event.nostr_published_wave_id:
continue
event.nostr_publish_pending = True
await update_event(event)
flagged += 1
return flagged
async def get_events_pending_republish() -> list[Event]:
"""Events whose relay copy may be behind this row.
`nostr_publish_pending` is set before every publish attempt and
cleared only on a confirmed success, so a row still flagged here
either failed to publish or never got the chance. Drives the
reconciliation sweep in `events_start`.
Ordered oldest-first so a backlog drains in the order it accrued.
"""
events = await db.fetchall(
"""
SELECT * FROM events.events
WHERE nostr_publish_pending = TRUE
ORDER BY time ASC
""",
model=Event,
)
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
# do. These four getters are fork-only, so upstream's v1.6.8 diff
# never reached them — and two of them publish: `get_all_events`
# backs /republish-all and `get_events_pending_republish` drives the
# #55 sweep. Without this they would emit the stale roll-up rather
# than the current per-wave figures.
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
async def get_settings() -> EventsSettings: async def get_settings() -> EventsSettings:

173
docs/rebase-playbook.md Normal file
View file

@ -0,0 +1,173 @@
# Rebase playbook
How to merge an upstream release into this fork without shipping the
failures a clean `git merge` cannot see.
Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two
instances showed up within minutes of each other. Both were textually
clean merges that would have been semantically wrong in production.
Modes C and D were added later in the same rebase, from `services.py`.
## The four failure modes
Git resolves _text_. Neither of these produces a conflict marker.
### A. Missed application
Upstream establishes an invariant and applies it at every call site **it
knows about**. The fork has additional call sites upstream cannot see,
so the invariant silently does not hold there.
> **Worked example.** v1.6.8 made `event.amount_tickets` a per-wave
> roll-up recomputed by `sync_event_ticket_waves`, and added that call to
> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the
> fork has four getters upstream never had — `get_all_events`,
> `get_public_events`, `get_pending_events`,
> `get_events_pending_republish` — and two of them _publish_
> (`/republish-all` and the #55 sweep). Without the same call they emit
> the stale roll-up, so waves would have been wrong on exactly the paths
> that push to relays, and nowhere else.
### B. Changed meaning
Upstream redefines what an existing field _means_. Fork code that reads
it is untouched by the diff and keeps compiling, while now saying
something false.
> **Worked example.** After `sync_event_ticket_waves`,
> `event.price_per_ticket` is the **primary (first)** wave's price and
> `event.amount_tickets` is the **sum across all waves**. Our
> `nostr_publisher.build_nip52_event` reads both. Merged untouched, it
> would advertise the early-bird price after early-bird closed, and count
> stock in waves that have not opened. See #61.
### C. Misplaced conflict boundary
Git anchors a conflict on whatever lines happen to match. When both sides
rewrote the same region, an _incidental_ shared line inside it can become
the anchor — and everything past that line lands **outside** the markers,
where it reads as cleanly merged.
Resolving only what sits between the markers then leaves **both**
implementations in the file. Python does not complain: the later `def`
silently wins. Since the merge appends upstream after ours, the survivor
is upstream's — the fork's version is shadowed without a single warning.
> **Worked example.** In `services.py` the notification stack conflicted.
> Ours (220 lines: multipart HTML mail, the QR-card attachment, the
> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support)
> appeared between the markers; upstream's showed as 4 lines. But both
> sides define the _same nine functions_, and git had anchored on a
> shared `_send_nostr_ticket_notification` line — so upstream's entire
> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and
> moving on would have left upstream's definitions last in the file and
> therefore live, quietly reverting every one of those features.
**Do not trust the marker as the edit's boundary.** Before resolving a
hunk, list the function names on each side and compare them to the names
already in the file:
```sh
grep -o '^\(async \)\?def \w*' <file>.py | sed 's/.*def //' | sort | uniq -d
```
Run that per file **as you resolve**, not at the end. `ruff` does not
flag redefinition (verified: F ruleset passes on a duplicated `def`).
Only `mypy` does, via `no-redef` — and mypy refuses to run at all while
any file in the package still has conflict markers, so the one tool that
catches mode C is unavailable for the whole merge. The `uniq -d` line
above is the substitute.
### D. Collided names
Ours and upstream independently grew a function with the **same name for
a different feature**. Every resolution that reads as sane — take ours,
take theirs, take "the newer one" — silently deletes a feature, and the
diff looks like an ordinary reconciliation of one function.
> **Worked example.** Both sides had `_ticket_image_url`. Ours: the
> rendered QR ticket-card PNG, always attached, served by this extension
> off `lnbits_baseurl`. Upstream's: an organiser-uploaded template, opt-in
> per wave via `use_ticket_image`, served off `ticket_base_url` and
> returning `None` when the wave has not enabled it. Same name, different
> arity, different return type, unrelated features. Resolved by renaming
> ours to `_ticket_card_url` and keeping both — upstream's ticket-image
> upload UI had already merged into `index.vue`, so dropping their backend
> would have orphaned live UI.
Signals worth stopping on: the two versions differ in **arity**, in
**return type** (`str` vs `str | None`), or in which setting they build a
URL from. Any of those means it is probably not one function with two
histories.
## The procedure
Run this _after_ the merge resolves and _before_ the release.
### 1. Enumerate what upstream introduced
```sh
MB=$(git merge-base HEAD upstream/main)
# new public names
git diff $MB..<tag> -- '*.py' | grep -E "^\+(def |class |async def )"
# fields whose meaning was redefined
git show <tag>:models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p'
```
Split the result into **new symbols** (mode A candidates) and
**redefined fields** (mode B candidates).
### 2. For each new symbol upstream _calls_, find the fork-only siblings
Ask what category of place the call belongs to — "every function that
returns an `Event` from the DB", "every path that prices a ticket" — then
enumerate that whole category in the merged tree and check coverage.
```sh
grep -n "sync_event_ticket_waves" crud.py # where upstream put it
grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py # where it belongs
```
The gap between those two lists is the work.
### 3. For each redefined field, grep the fork-only files
The 30-odd files upstream has never seen are where mode B hides, because
nothing in the diff touches them:
```sh
git diff --name-only $MB..HEAD > /tmp/fork.txt
git diff --name-only $MB..<tag> > /tmp/up.txt
comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt) # fork-only files
grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...)
```
### 4. Prove each finding before fixing it
Both examples above were confirmed by reading the code path end to end,
not inferred from the diff. A wrong theory costs more than the check:
during this rebase an inference that `amount_tickets` "goes stale on
sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which
only the call-site list showed.
### 5. Write the reason at the site
Every fix from this procedure gets a comment saying **why upstream's diff
missed it**. That is what stops the next rebase re-dropping it, and it is
the only durable record that the omission was considered rather than
overlooked.
## Checklist
- [ ] `migrations.py` still byte-identical to upstream
- [ ] New upstream symbols enumerated; each call-site category audited
- [ ] Redefined fields enumerated; every fork-only reader checked
- [ ] Fork-only files listed and grepped for both modes
- [ ] Every resolved file checked for duplicate `def`s (mode C) — as it is
resolved, since mypy cannot run until the whole package is clean
- [ ] Same-named functions on both sides compared by arity and return type
before being treated as one function (mode D)
- [ ] Publishing paths specifically audited — they fail silently and
externally, so they are the worst place for either mode to land
- [ ] Every fix carries a comment explaining the omission
- [ ] Deviations recorded in `docs/upstream-candidates.md`

View file

@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
in an upstream-compatible form; strike it when the PR merges upstream. in an upstream-compatible form; strike it when the PR merges upstream.
| Feature | Where | Upstream target | Readiness | | Feature | Where | Upstream target | Readiness |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | | `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | | Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | | `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
@ -15,3 +15,7 @@ in an upstream-compatible form; strike it when the PR merges upstream.
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | | Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | | Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | | NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 |

View file

@ -127,3 +127,61 @@ async def m002_ticket_payment_hash(db):
"UPDATE events.ticket SET payment_hash = id " "UPDATE events.ticket SET payment_hash = id "
"WHERE payment_hash IS NULL OR payment_hash = ''" "WHERE payment_hash IS NULL OR payment_hash = ''"
) )
async def m003_event_nostr_publish_pending(db):
"""
Add `events.nostr_publish_pending` — the marker that makes NIP-52
publish drift queryable instead of invisible.
Inventory reaches clients only through the republished calendar
event. When that publish doesn't land, the relay keeps serving the
counts it last saw and nothing anywhere records the divergence; it
has twice been caught only by a human reading a public page
(aiolabs/events#35, #51).
The flag is set before each publish attempt and cleared only on a
confirmed success, so it covers *both* observed failure shapes:
an attempt that raised (a signer outage) and an attempt that was
never made at all (no signer resolved, no NostrClient). A periodic
sweep republishes whatever is still marked.
Existing rows default to FALSE rather than TRUE: on upgrade we have
no evidence they're stale, and marking the whole table pending would
stampede the signer with a full-table republish on first boot.
`/republish-all` is the deliberate way to force that.
"""
await _alter_add_column_safe(
db,
"ALTER TABLE events.events "
"ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE",
)
async def m004_event_nostr_published_wave(db):
"""
Add `events.nostr_published_wave_id` — which ticket wave the last
successful NIP-52 publish advertised.
Since upstream v1.6.8 price and inventory live on time-boxed waves, so
what a calendar event should advertise changes at a *date* boundary.
Every republish we have is sale-driven, and no sale happens at
midnight when early bird ends — so without this the relay keeps
serving the closed wave's price until the next ticket sells
(aiolabs/events#61).
Recording the advertised wave makes that drift detectable with the
machinery already in place: the reconciliation sweep compares this
against the wave that would be advertised now and sets
`nostr_publish_pending`, reusing the existing retry path rather than
adding a scheduler.
NULL on existing rows means "never published, or published before this
column existed". The sweep treats NULL as "no evidence of drift" and
leaves it alone, so an upgrade does not stampede the signer with a
full-table republish; the first ordinary publish fills it in.
"""
await _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT",
)

269
models.py
View file

@ -1,7 +1,9 @@
import json import json
from datetime import datetime from datetime import date, datetime
from urllib.parse import urlsplit from urllib.parse import urlsplit
from uuid import uuid4
from lnbits.db import FilterModel
from pydantic import BaseModel, EmailStr, Field, root_validator, validator from pydantic import BaseModel, EmailStr, Field, root_validator, validator
PAYMENT_METHODS = ("lightning", "fiat") PAYMENT_METHODS = ("lightning", "fiat")
@ -11,20 +13,56 @@ class PromoCode(BaseModel):
code: str code: str
discount_percent: float = 0.0 discount_percent: float = 0.0
active: bool = True active: bool = True
# Redemption cap; None / 0 = unlimited. Field names follow upstream v2.
max_uses: int | None = None
# Derived on read from PAID tickets whose extra.applied_promo_code matches
# (see promo.promo_usage / services.hydrate_promo_usage). Whatever a
# client sends back here is ignored — it is never the source of truth.
used_count: int = 0
# make the promo code uppercase # stored form: stripped + upper-case, never empty
@validator("code") @validator("code")
def uppercase_code(cls, v): def uppercase_code(cls, v):
return v.upper() v = (v or "").strip().upper()
if not v:
raise ValueError("Promo code cannot be empty.")
return v
@validator("discount_percent") @validator("discount_percent")
def validate_discount_percent(cls, v): def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100." assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v return v
@validator("max_uses", pre=True)
def normalize_max_uses(cls, v):
if v in (None, "", 0, "0"):
return None
v = int(v)
if v < 1:
raise ValueError("max_uses must be at least 1.")
return v
class TicketWave(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex[:8])
title: str = "Primary wave"
opening_date: str
closing_date: str
currency: str = "sat"
use_ticket_image: bool = False
ticket_image_id: str | None = None
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = Field(default=0, ge=0)
price_per_ticket: float = Field(default=0, ge=0)
class EventExtraBase(BaseModel):
"""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."""
class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False conditional: bool = False
min_tickets: int = 1 min_tickets: int = 1
email_notifications: bool = False email_notifications: bool = False
@ -36,6 +74,19 @@ class EventExtra(BaseModel):
# `effective_payment_methods`. Same field name/shape as upstream v2 so the # `effective_payment_methods`. Same field name/shape as upstream v2 so the
# eventual rebase (#33) merges cleanly. # eventual rebase (#33) merges cleanly.
payment_methods: list[str] = Field(default_factory=list) 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) @validator("payment_methods", pre=True)
def normalize_payment_methods(cls, v): def normalize_payment_methods(cls, v):
@ -53,6 +104,13 @@ class EventExtra(BaseModel):
return seen return seen
class EventExtra(EventExtraBase):
promo_codes: list[PromoCode] = Field(default_factory=list)
PublicEventExtra = EventExtraBase
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required) name: str # title (required)
@ -65,7 +123,12 @@ class CreateEvent(BaseModel):
currency: str = "sat" currency: str = "sat"
allow_fiat: bool = False allow_fiat: bool = False
fiat_currency: str = "GBP" fiat_currency: str = "GBP"
amount_tickets: int = 0 # 0 = unlimited / not ticketed # Capacity is always required and there is no unlimited (#34): a zero
# here means sold out / not sellable, which `api_get_event` and
# `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this
# is only the seed for the primary wave — `sync_event_ticket_waves`
# recomputes it as the sum of every wave's remaining stock.
amount_tickets: int = 0
price_per_ticket: float = 0 # 0 = free price_per_ticket: float = 0 # 0 = free
banner: str | None = None banner: str | None = None
location: str | None = None # venue/address (NIP-52 'location' tag) location: str | None = None # venue/address (NIP-52 'location' tag)
@ -97,6 +160,16 @@ class Event(BaseModel):
status: str = "approved" status: str = "approved"
nostr_event_id: str | None = None nostr_event_id: str | None = None
nostr_event_created_at: int | None = None nostr_event_created_at: int | None = None
# Set before every publish attempt, cleared on confirmed success.
# True means the relay's copy may be behind this row — see
# migrations_fork.m003 and the sweep in __init__.events_start.
nostr_publish_pending: bool = False
# Which wave the last successful publish advertised (see
# `advertised_wave_key`). NULL = never published; "" = published while
# nothing was on sale. The sweep compares this against the current key
# to catch wave boundaries, which are time-driven and so fire no
# sale-triggered republish (aiolabs/events#61).
nostr_published_wave_id: str | None = None
@validator("categories", pre=True) @validator("categories", pre=True)
def parse_categories(cls, v): def parse_categories(cls, v):
@ -120,7 +193,9 @@ class PublicEvent(BaseModel):
banner: str | None banner: str | None
location: str | None = None location: str | None = None
categories: list[str] = Field(default_factory=list) categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra) # PublicEventExtra: promo codes are organizer-only (a buyer who can read
# every code can mint every discount).
extra: PublicEventExtra = Field(default_factory=PublicEventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True) @validator("categories", pre=True)
@ -130,22 +205,69 @@ class PublicEvent(BaseModel):
return v or [] return v or []
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]: def effective_payment_methods(
event: "Event | PublicEvent | CreateEvent",
wave: "TicketWave | None" = None,
) -> list[str]:
"""Rails a buyer may pick for `event`. """Rails a buyer may pick for `event`.
Explicit `extra.payment_methods` wins; an empty list falls back to the Explicit `extra.payment_methods` wins; an empty list falls back to the
pre-#payment-methods rule so events created before the field existed pre-#payment-methods rule so events created before the field existed
keep behaving the same (Lightning always, fiat iff `allow_fiat`). keep behaving the same (Lightning always, fiat iff `allow_fiat`).
Pass `wave` when the answer is about one specific ticket wave. Fiat is a
per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY
wave's, so without it a publisher can advertise a fiat rail for an
advertised wave that does not accept fiat — which the purchase endpoint
then rejects (aiolabs/events#61). Callers asking the event-level
question ("which rails did the organiser enable at all") leave it unset.
""" """
explicit = list(getattr(event.extra, "payment_methods", []) or []) explicit = list(getattr(event.extra, "payment_methods", []) or [])
if explicit: 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 return explicit
methods = ["lightning"] methods = ["lightning"]
if event.allow_fiat: if wave.allow_fiat if wave is not None else event.allow_fiat:
methods.append("fiat") methods.append("fiat")
return methods return methods
class PromoValidateRequest(BaseModel):
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a
plain `quantity` against one ticket wave.
`ticket_wave_id` may be omitted when exactly one wave is open, matching
how the purchase endpoint resolves it — the preview has to price the same
wave the invoice will, and since v1.6.8 price and currency are per-wave.
"""
codes: list[str] = Field(default_factory=list)
quantity: int = Field(default=1, ge=1, le=10)
ticket_wave_id: str | None = None
class BasketDiscount(BaseModel):
code: str
discount_percent: float | None = None
discount_fixed: int | None = None # always None here (percent-only); v2 shape
amount_saved: float = 0
class BasketTotals(BaseModel):
subtotal: float = 0
discount: float = 0
total: float = 0
discounts_applied: list[BasketDiscount] = Field(default_factory=list)
currency: str = "sat" # fork addition so a client can format the numbers
class EventsSettings(BaseModel): class EventsSettings(BaseModel):
"""Extension-level settings for the events extension.""" """Extension-level settings for the events extension."""
@ -154,6 +276,8 @@ class EventsSettings(BaseModel):
class TicketExtra(BaseModel): class TicketExtra(BaseModel):
applied_promo_code: str | None = None applied_promo_code: str | None = None
ticket_wave_id: str | None = None
ticket_wave_title: str | None = None
sats_paid: int | None = None sats_paid: int | None = None
refund_address: str | None = None refund_address: str | None = None
nostr_identifier: str | None = None nostr_identifier: str | None = None
@ -161,12 +285,18 @@ class TicketExtra(BaseModel):
email_notification_sent: bool = False email_notification_sent: bool = False
nostr_notification_sent: bool = False nostr_notification_sent: bool = False
refunded: bool = False refunded: bool = False
# On-chain vocabulary, populated once native lnbits on-chain lands
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
# id — SatsPay is the implementation we are not adopting.
onchain: bool = False
onchain_address: str | None = None
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
name: str | None = None name: str | None = None
email: EmailStr | None = None email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email) user_id: str | None = None # LNbits user id (alternative to name+email)
ticket_wave_id: str | None = None
promo_code: str | None = None promo_code: str | None = None
refund_address: str | None = None refund_address: str | None = None
nostr_identifier: str | None = None nostr_identifier: str | None = None
@ -268,3 +398,124 @@ class TicketPaymentRequest(BaseModel):
# the door). Buyers fetch these after payment to render N QRs in # the door). Buyers fetch these after payment to render N QRs in
# My Tickets. # My Tickets.
ticket_ids: list[str] = Field(default_factory=list) ticket_ids: list[str] = Field(default_factory=list)
onchain_amount_sat: int | None = None
class TicketFilters(FilterModel):
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
__sort_fields__ = [ # noqa: RUF012
"time",
"event",
"name",
"email",
"registered",
"id",
]
event: str | None = None
name: str | None = None
email: str | None = None
registered: bool | None = None
paid: bool | None = None
id: str | None = None
def _parse_date(value: str) -> date:
"""Date component of `value`.
Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a
plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may
carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults
`closing_date` to it, so a wave derived from an event inherits the full
ISO datetime and upstream's parser raises
`ValueError: unconverted data remains: T18:00:00` — on the purchase path,
the public event gate, and the promo preview.
"""
return date.fromisoformat(value[:10])
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
if ticket_waves:
return ticket_waves
# `TicketWave` requires both dates; `Event.closing_date` is Optional in
# this fork (it defaults from event_end_date at create time), so fall
# back the same way `create_event` does rather than handing None to a
# required field.
closing_date = event.closing_date or event.event_end_date or event.event_start_date
fallback_opening_date = None
event_time = getattr(event, "time", None)
if event_time:
fallback_opening_date = event_time.date().isoformat()
if not fallback_opening_date:
fallback_opening_date = closing_date
return [
TicketWave(
id="primary",
title="Primary wave",
opening_date=fallback_opening_date,
closing_date=closing_date,
currency=event.currency,
allow_fiat=event.allow_fiat,
fiat_currency=event.fiat_currency,
amount_tickets=getattr(event, "amount_tickets", 0),
price_per_ticket=event.price_per_ticket,
)
]
def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None":
"""The wave a public listing should describe, or None when nothing is
on sale (sold out, between waves, or not yet open).
Several waves can be open at once. The purchase endpoint refuses to
guess; a publisher has no one to ask, so it advertises the CHEAPEST
open wave — the price a buyer is actually able to obtain.
Shared by the NIP-52 publisher and the wave-transition detector so the
two cannot disagree about which wave is currently being advertised.
"""
active = get_active_ticket_waves(event)
if not active:
return None
return min(active, key=lambda wave: wave.price_per_ticket)
def advertised_wave_key(event: "Event | PublicEvent") -> str:
"""Stable key for what a publish advertised. Empty string means "nothing
on sale", which is a real published state and distinct from NULL in
`nostr_published_wave_id` (never published)."""
wave = advertised_ticket_wave(event)
return wave.id if wave else ""
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
ticket_waves = ensure_ticket_waves(event)
event.extra.ticket_waves = ticket_waves
primary_wave = ticket_waves[0]
event.closing_date = max(wave.closing_date for wave in ticket_waves)
event.currency = primary_wave.currency
event.allow_fiat = primary_wave.allow_fiat
event.fiat_currency = primary_wave.fiat_currency
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
event.price_per_ticket = primary_wave.price_per_ticket
return event
def get_active_ticket_waves(
event: Event | PublicEvent, today: date | None = None
) -> list[TicketWave]:
current_day = today or datetime.utcnow().date()
return [
wave
for wave in ensure_ticket_waves(event)
if _parse_date(wave.opening_date)
<= current_day
<= _parse_date(wave.closing_date)
and wave.amount_tickets > 0
]

View file

@ -19,6 +19,14 @@ from websocket import WebSocketApp
from .event import NostrEvent from .event import NostrEvent
MAX_SEEN_EVENTS = 500 MAX_SEEN_EVENTS = 500
# How many times a dequeued req is retried before it is dropped. Bounded
# so one unsendable message can't block every later publish behind it.
MAX_SEND_ATTEMPTS = 3
# How long to wait for the relay's `OK` before treating a publish as
# unconfirmed. nostrclient's router answers within its own
# PUBLISH_TIMEOUT_SECONDS (10s) even when every relay stays silent, so
# this only needs headroom over that.
PUBLISH_OK_TIMEOUT_SECONDS = 12
class NostrClient: class NostrClient:
@ -29,6 +37,10 @@ class NostrClient:
self.subscription_id = "events-" + urlsafe_short_hash()[:32] self.subscription_id = "events-" + urlsafe_short_hash()[:32]
self.running = False self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict() self._seen_events: OrderedDict[str, None] = OrderedDict()
# event id -> future awaiting that publish's `OK`. Resolved in
# `get_event`, which is the single point where relay messages
# cross into the event loop.
self._pending_oks: dict[str, asyncio.Future] = {}
@property @property
def is_websocket_connected(self): def is_websocket_connected(self):
@ -78,17 +90,37 @@ class NostrClient:
async def run_forever(self): async def run_forever(self):
self.running = True self.running = True
# A req that was dequeued but whose send raised. It is already
# off the queue, so dropping it loses the publish outright and
# the caller has long since been told it succeeded (the queue
# put returns immediately). Hold it across the reconnect and
# retry instead.
held_req: list | None = None
held_attempts = 0
while self.running: while self.running:
try: try:
if not self.is_websocket_connected: if not self.is_websocket_connected:
self.ws = await self.connect() self.ws = await self.connect()
await asyncio.sleep(5) await asyncio.sleep(5)
if held_req is not None:
req = held_req
else:
req = await self.send_req_queue.get() req = await self.send_req_queue.get()
held_req, held_attempts = req, held_attempts + 1
assert self.ws assert self.ws
self.ws.send(json.dumps(req)) self.ws.send(json.dumps(req))
held_req, held_attempts = None, 0
except Exception as ex: except Exception as ex:
logger.warning(f"[EVENTS] NostrClient error: {ex}") logger.warning(f"[EVENTS] NostrClient error: {ex}")
if held_req is not None and held_attempts >= MAX_SEND_ATTEMPTS:
# Bounded: a req the relay or the socket will never
# accept must not wedge the queue behind it forever.
logger.error(
f"[EVENTS] Dropping req after {held_attempts} "
f"failed sends: {held_req[0]}"
)
held_req, held_attempts = None, 0
await asyncio.sleep(60) await asyncio.sleep(60)
def is_duplicate_event(self, event_id: str) -> bool: def is_duplicate_event(self, event_id: str) -> bool:
@ -101,14 +133,82 @@ class NostrClient:
return False return False
async def get_event(self): async def get_event(self):
"""Get next event from the receive queue.""" """Get the next relay message, consuming `OK` frames on the way.
This is the only place relay messages cross from the websocket
thread into the event loop, which makes it the natural place to
settle publish confirmations — no cross-thread future juggling.
`OK` frames are swallowed rather than forwarded; the sync loop
never handled them.
"""
while True:
value = await self.receive_event_queue.get() value = await self.receive_event_queue.get()
if isinstance(value, ValueError): if isinstance(value, ValueError):
self._fail_pending_oks("connection closed")
raise value raise value
if self._settle_ok(value):
continue
return value return value
async def publish_nostr_event(self, e: NostrEvent): def _settle_ok(self, message) -> bool:
"""Resolve the future for an `["OK", <id>, <bool>, <msg>]` frame.
Returns True when `message` was an OK frame (and so should not
be forwarded), False otherwise. An OK for a publish we are not
waiting on — a retry whose original already timed out, say — is
still consumed; it has nowhere useful to go.
"""
try:
data = json.loads(message)
except (json.JSONDecodeError, TypeError):
return False
if not isinstance(data, list) or len(data) < 3 or data[0] != "OK":
return False
event_id = data[1]
accepted = bool(data[2])
detail = data[3] if len(data) > 3 and isinstance(data[3], str) else ""
future = self._pending_oks.get(event_id)
if future and not future.done():
future.set_result((accepted, detail))
return True
def _fail_pending_oks(self, reason: str) -> None:
"""Settle every in-flight publish as unconfirmed.
Without this a disconnect leaves callers waiting the full
timeout for an `OK` that can no longer arrive.
"""
for future in self._pending_oks.values():
if not future.done():
future.set_result((False, f"error: {reason}"))
async def publish_nostr_event(self, e: NostrEvent) -> bool:
"""Publish and wait for the relay's `OK`. True only when accepted.
Queueing is not delivery: nostrclient drops an EVENT outright
when no relay is connected, answering `OK false`. Reporting
success on the queue put let a stale ticket count survive a
republish that never left the building (aiolabs/events#56).
"""
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending_oks[e.id] = future
try:
await self.send_req_queue.put(["EVENT", e.dict()]) await self.send_req_queue.put(["EVENT", e.dict()])
accepted, detail = await asyncio.wait_for(
future, PUBLISH_OK_TIMEOUT_SECONDS
)
if not accepted:
logger.warning(f"[EVENTS] Relay rejected event {e.id[:12]}…: {detail}")
return accepted
except asyncio.TimeoutError:
logger.warning(
f"[EVENTS] No OK for event {e.id[:12]}… within "
f"{PUBLISH_OK_TIMEOUT_SECONDS}s — treating as unconfirmed"
)
return False
finally:
self._pending_oks.pop(e.id, None)
async def subscribe(self, filters: list[dict]): async def subscribe(self, filters: list[dict]):
"""Subscribe to events matching the given filters.""" """Subscribe to events matching the given filters."""

View file

@ -8,11 +8,11 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
from loguru import logger from loguru import logger
from .crud import update_event from .crud import update_event
from .models import Event from .models import Event, advertised_wave_key
from .nostr_publisher import publish_event_to_nostr from .nostr_publisher import publish_event_to_nostr
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None: async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool:
"""Publish or delete the NIP-52 calendar event for `event`. """Publish or delete the NIP-52 calendar event for `event`.
Resolves a `NostrSigner` for the wallet owner — backend-agnostic Resolves a `NostrSigner` for the wallet owner — backend-agnostic
@ -22,7 +22,22 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
`await signer.sign_event(...)` for signing. Failures are logged `await signer.sign_event(...)` for signing. Failures are logged
and swallowed so a Nostr outage doesn't break the HTTP flow that and swallowed so a Nostr outage doesn't break the HTTP flow that
triggered the publish. triggered the publish.
Returns True when the event was signed and handed to the client,
False on any skip or failure. Callers are free to ignore it — the
`nostr_publish_pending` flag is the durable record, and the sweep
retries from that rather than from a return value.
""" """
# Mark before attempting, clear only on confirmed success. Doing it
# in this order is what makes "the attempt was never made" — no
# signer, no NostrClient, process died mid-flight — as visible as
# "the attempt raised". Cheap guard so a re-publish of an already
# pending row doesn't write twice; `set_ticket_paid` sets the flag
# inside its own update so the sale path adds no extra write.
if not event.nostr_publish_pending:
event.nostr_publish_pending = True
await update_event(event)
try: try:
from lnbits.core.signers import resolve_for_wallet from lnbits.core.signers import resolve_for_wallet
@ -32,17 +47,40 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
if signer is None: if signer is None:
# Wallet missing, account missing, unclassified row, or # Wallet missing, account missing, unclassified row, or
# ClientSideOnlySigner account (server can't sign for them). # ClientSideOnlySigner account (server can't sign for them).
# Soft-fail: skip the publish silently. The user can still # Soft-fail: the HTTP / payment flow that triggered this must
# publish kind-31922/31923 events client-side once we have # not break. The user can still publish kind-31922/31923
# that path. # events client-side once we have that path.
return #
# Logged at WARNING, not debug: skipping the publish means the
# relay keeps serving whatever inventory it last saw, so the
# public ticket count silently stops tracking the DB. That has
# twice been discovered only by a human noticing a wrong number
# on a public page (aiolabs/events#35, #51).
logger.warning(
f"[EVENTS] No signer for wallet {event.wallet}, skipping "
f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}"
)
return False
nostr_event = await publish_event_to_nostr( nostr_event = await publish_event_to_nostr(
nostr_client, event, signer, delete=delete nostr_client, event, signer, delete=delete
) )
if nostr_event and not delete: if nostr_event is None:
return False
event.nostr_publish_pending = False
if not delete:
event.nostr_event_id = nostr_event.id event.nostr_event_id = nostr_event.id
event.nostr_event_created_at = nostr_event.created_at event.nostr_event_created_at = nostr_event.created_at
# Record which wave this publish advertised so the sweep can
# notice a wave boundary later (aiolabs/events#61). Written in
# the same update as the cleared flag, so the two can never
# disagree about what is on the relay.
event.nostr_published_wave_id = advertised_wave_key(event)
await update_event(event) await update_event(event)
return True
except Exception as exc: except Exception as exc:
logger.warning(f"[EVENTS] Nostr publish failed: {exc}") # ERROR, not warning: the row stays flagged and its published
# counts stay behind until the sweep or a later edit succeeds.
logger.error(f"[EVENTS] Nostr publish failed for event {event.id}: {exc}")
return False

View file

@ -17,7 +17,12 @@ from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner from lnbits.core.signers import NostrSigner
from loguru import logger 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.event import NostrEvent
from .nostr_timestamp import monotonic_created_at 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) start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional) end - same encoding (optional)
image, location, t (categories) - optional image, location, t (categories) - optional
tickets_available - current remaining capacity (omitted when unlimited) tickets_available - remaining capacity of the advertised wave
tickets_sold - running paid-count (always emitted; clients can (always emitted; 0 = nothing on sale now)
derive original_capacity = available + sold) tickets_sold - running paid-count across all waves (always
tickets_price - price_per_ticket (always emitted; 0 means free) emitted)
tickets_currency - the currency string 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_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat) tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info Content: event.info
@ -96,25 +103,59 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
for cat in event.categories or []: for cat in event.categories or []:
tags.append(["t", cat]) tags.append(["t", cat])
# `amount_tickets == 0` means unlimited capacity in this extension's # Always emitted, including zero. Omitting it used to mean "unlimited",
# schema. Omitting the tag is how clients distinguish unlimited from # which contradicted every other reader: `api_get_event` and
# "0 left" (sold out). # `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a
if event.amount_tickets > 0: # zero-capacity event advertised "Unlimited tickets" on the card while
tags.append(["tickets_available", str(event.amount_tickets)]) # 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.
#
# 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_sold", str(event.sold)])
tags.append(["tickets_price", str(event.price_per_ticket)]) tags.append(["tickets_price", str(advertised.price_per_ticket)])
tags.append(["tickets_currency", event.currency]) tags.append(["tickets_currency", advertised.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so # Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema. # 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"]) tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency: if advertised.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency]) tags.append(["tickets_fiat_currency", advertised.fiat_currency])
# Rails the organizer accepts, resolved through the same helper the # Rails the organizer accepts, resolved through the same helper the
# ticket endpoint enforces with, so a client can render exactly 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 # buttons that will be accepted (e.g. a card-only event) without a REST
# round-trip. Comma-separated, lowercase. # 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 # NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic # whenever inventory changes (a ticket sells). Use a strictly-monotonic
@ -172,7 +213,14 @@ async def publish_event_to_nostr(
Returns the published NostrEvent for metadata storage, or None on failure. Returns the published NostrEvent for metadata storage, or None on failure.
""" """
if not nostr_client: if not nostr_client:
logger.debug("[EVENTS] No NostrClient available, skipping publish") # WARNING, not debug: with no client the event is never queued, so
# the relay keeps serving stale inventory and nothing downstream
# can tell. At debug this skip is invisible at the INFO level
# instances actually run at (aiolabs/events#35, #51).
logger.warning(
"[EVENTS] No NostrClient, skipping NIP-52 "
f"{'delete' if delete else 'publish'} for event {event.id}"
)
return None return None
try: try:
@ -196,7 +244,16 @@ async def publish_event_to_nostr(
nostr_event.pubkey = signed["pubkey"] nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"] nostr_event.sig = signed["sig"]
await nostr_client.publish_nostr_event(nostr_event) accepted = await nostr_client.publish_nostr_event(nostr_event)
if not accepted:
# Returning None keeps `nostr_publish_pending` set, so the
# sweep retries instead of recording a delivery that never
# happened (aiolabs/events#56).
logger.warning(
f"[EVENTS] Relay did not confirm NIP-52 "
f"{'delete' if delete else 'calendar'} event for {event.id}"
)
return None
logger.info( logger.info(
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} " f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
@ -205,5 +262,8 @@ async def publish_event_to_nostr(
return nostr_event return nostr_event
except Exception as e: except Exception as e:
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}") # ERROR, not warning: this is the signer-outage shape of
# aiolabs/events#35 — the calendar event never reaches the relay
# and the published ticket counts stop tracking the DB.
logger.error(f"[EVENTS] Failed to publish event {event.id} to Nostr: {e}")
return None return None

117
promo.py Normal file
View file

@ -0,0 +1,117 @@
"""Promo-code arithmetic shared by the validate endpoint and the purchase path.
Pure functions (no DB, no settings) so the number a buyer sees in the
"Apply" preview is exactly the number the invoice / Stripe session charges.
Field names and the `BasketTotals` shape follow upstream lnbits/events v2
(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted
inline.
"""
from __future__ import annotations
from collections import Counter
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave
SAT_UNITS = ("sat", "sats")
def normalize_code(raw: str | None) -> str | None:
"""Buyer input → stored form (stripped, upper-cased); empty → None."""
if raw is None:
return None
code = raw.strip().upper()
return code or None
def find_promo(event: Event, code: str) -> PromoCode | None:
return next((pc for pc in event.extra.promo_codes if pc.code == code), None)
def promo_usage(tickets: list[Ticket]) -> dict[str, int]:
"""Redemptions per code = PAID tickets carrying it in
`extra.applied_promo_code`. Every row counts, so a multi-ticket
purchase consumes `quantity` uses (upstream v2 counts one per basket).
Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so
counting them would let an abandoned Stripe session lock out the last
uses of a limited code for a day. The cost is a bounded overshoot when
several buyers pass the check before any of them pays — accepted.
"""
counter: Counter[str] = Counter()
for ticket in tickets:
code = ticket.extra.applied_promo_code
if ticket.paid and code:
counter[code] += 1
return dict(counter)
def remaining_uses(promo: PromoCode, used: int) -> int | None:
"""None = unlimited (`max_uses` unset / 0)."""
if not promo.max_uses:
return None
return max(promo.max_uses - used, 0)
def round_amount(amount: float, currency: str | None) -> float:
"""Sats are integers; fiat is 2 dp. Applied once, at the end, so
subtotal - total == discount holds for what is actually charged."""
if (currency or "sat").lower() in SAT_UNITS:
return float(int(amount))
return round(amount, 2)
def basket_totals(
event: Event,
codes: list[str],
quantity: int,
usage: dict[str, int],
wave: TicketWave,
) -> BasketTotals:
"""Price `quantity` tickets from `wave` with the first applicable code.
A code is applicable when it exists, is active, has enough uses left
for the whole quantity, and actually saves something. Anything else is
simply absent from `discounts_applied` (upstream v2 semantics — the
purchase endpoint is where hard errors are raised). Only one code is
applied; v2's `combinable` stacking is out of scope here.
`wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's
price and currency belong to the wave it is bought from, and the
event-level fields are a derived roll-up of the PRIMARY wave
(`sync_event_ticket_waves`) — pricing off `event` would quote and charge
the first wave's price to a buyer who picked a later one. It is a
required argument rather than an optional override precisely because
that failure is silent: both call sites have to name the wave.
"""
currency = wave.currency or "sat"
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
totals = BasketTotals(
subtotal=subtotal, discount=0, total=subtotal, currency=currency
)
for raw in codes:
code = normalize_code(raw)
if not code:
continue
promo = find_promo(event, code)
if not promo or not promo.active:
continue
remaining = remaining_uses(promo, usage.get(promo.code, 0))
if remaining is not None and remaining < quantity:
continue
total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency)
saved = round_amount(subtotal - total, currency)
if saved <= 0:
continue
totals.total = total
totals.discount = saved
totals.discounts_applied = [
BasketDiscount(
code=promo.code,
discount_percent=promo.discount_percent,
discount_fixed=None,
amount_saved=saved,
)
]
break
return totals

254
qr.py Normal file
View file

@ -0,0 +1,254 @@
"""QR + ticket-card rendering shared by the API and the mailer.
`make_qr_png` is upstream v1.6.8's helper (pyqrcode + Pillow) with the
instance QR logo pasted in the centre; `render_ticket_card` wraps it in a
self-describing card (event, when, where, name, ticket id) so the PNG a
buyer saves from the email still says what it is for.
"""
from __future__ import annotations
import re
from datetime import datetime
from io import BytesIO
from pathlib import Path
import httpx
import pyqrcode # type: ignore[import-untyped]
from lnbits.settings import settings
from loguru import logger
from PIL import Image, ImageDraw, ImageFont
from .models import Event, Ticket
_qr_logo_cache: dict[str, Image.Image | None] = {}
async def load_qr_logo() -> Image.Image | None:
"""LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached).
Local `/static/...` values resolve inside the LNbits package; absolute
URLs are fetched once. Any failure just yields a plain QR.
"""
source = (settings.lnbits_qr_logo or "").strip()
if not source:
return None
if source in _qr_logo_cache:
return _qr_logo_cache[source]
logo: Image.Image | None = None
try:
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(source)
resp.raise_for_status()
logo = Image.open(BytesIO(resp.content)).convert("RGBA")
else:
local = Path(settings.lnbits_path) / source.lstrip("/")
if local.is_file():
logo = Image.open(local).convert("RGBA")
except Exception as exc:
logger.warning(f"QR logo '{source}' unavailable: {exc}")
logo = None
_qr_logo_cache[source] = logo
return logo
def make_qr_png(
data: str,
size: int = 235,
border: int = 4,
logo: Image.Image | None = None,
) -> Image.Image:
"""Render `data` as a QR image. With `logo`, the code is built at
error-correction level H and the logo is pasted in the centre on a white
pad at ≤ 20 % of the width — the same look LNbits' client-side
`lnbits-qrcode` component produces."""
qr = pyqrcode.create(data, error="H" if logo is not None else "M")
matrix = qr.code
modules = len(matrix)
total_modules = modules + border * 2
box_size = max(1, size // total_modules)
img_size = total_modules * box_size
img = Image.new("RGBA", (img_size, img_size), "white")
draw = ImageDraw.Draw(img)
for y, row in enumerate(matrix):
for x, cell in enumerate(row):
if cell:
x0 = (x + border) * box_size
y0 = (y + border) * box_size
draw.rectangle(
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
fill="black",
)
if img_size != size:
img = img.resize((size, size), Image.Resampling.NEAREST)
if logo is not None:
logo_size = max(8, int(size * 0.2))
pad = max(2, logo_size // 8)
scaled = logo.copy()
scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
plate = Image.new(
"RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
)
plate.paste(scaled, (pad, pad), scaled)
img.paste(
plate,
((size - plate.width) // 2, (size - plate.height) // 2),
plate,
)
return img
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
try:
return datetime.strptime(value[:10], "%Y-%m-%d")
except ValueError:
return None
def format_event_when(event: Event) -> str:
"""'Fri 19 Feb 2027, 16:00 - 20:00' (same day) or a full range; the raw
strings when they do not parse."""
start = _parse_iso(event.event_start_date)
end = _parse_iso(event.event_end_date)
if not start:
return event.event_start_date or ""
has_time = "T" in (event.event_start_date or "")
day = start.strftime("%a %d %b %Y")
if not end or end == start:
return f"{day}, {start.strftime('%H:%M')}" if has_time else day
if end.date() == start.date():
if has_time:
return f"{day}, {start.strftime('%H:%M')} - {end.strftime('%H:%M')}"
return day
end_day = end.strftime("%a %d %b %Y")
if has_time:
return f"{day} {start.strftime('%H:%M')} - {end_day} {end.strftime('%H:%M')}"
return f"{day} - {end_day}"
def ticket_card_filename(ticket: Ticket, event: Event) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", event.name.lower()).strip("-")[:40] or "event"
return f"ticket-{slug}-{ticket.id[:8]}.png"
_FONT_DIR = Path(__file__).resolve().parent / "static" / "fonts"
def _font(
size: int, bold: bool = False
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
"""DejaVu Sans shipped with the extension (full Latin coverage — the
Pillow-bundled default lacks accented glyphs, so 'Château' would render
as tofu); Pillow's default is the fallback."""
path = _FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf")
try:
return ImageFont.truetype(str(path), size)
except OSError:
try:
return ImageFont.load_default(size=size)
except Exception: # very old Pillow: bitmap default only
return ImageFont.load_default()
def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
lines: list[str] = []
for paragraph in text.split("\n"):
words = paragraph.split()
line = ""
for word in words:
candidate = f"{line} {word}".strip()
if draw.textlength(candidate, font=font) <= max_width or not line:
line = candidate
else:
lines.append(line)
line = word
lines.append(line)
return lines
def render_ticket_card(
ticket: Ticket,
event: Event,
*,
logo: Image.Image | None = None,
site_title: str | None = None,
width: int = 800,
) -> Image.Image:
"""A self-describing ticket: header (site), event name, when/where, the
QR, then name on ticket + ticket id + door instruction."""
pad = 48
inner = width - 2 * pad
title_font = _font(40, bold=True)
body_font = _font(28)
small_font = _font(22)
mono_font = _font(24)
# Measure first: the card grows with the wrapped title.
probe = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
title_lines = _wrap(probe, event.name, title_font, inner)
when = format_event_when(event)
meta_lines = [when] if when else []
if event.location:
meta_lines += _wrap(probe, event.location, body_font, inner)
qr_size = min(inner, 560)
detail_lines = []
if ticket.name:
detail_lines.append(f"Name: {ticket.name}")
detail_lines.append(f"Ticket {ticket.id}")
y = pad
y += 30 + 16 # site title line
y += len(title_lines) * 52 + 12
y += len(meta_lines) * 36 + 28
qr_y = y
y += qr_size + 28
y += len(detail_lines) * 36 + 12
y += 30 + pad # footer
height = y
img = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(img)
grey = (110, 110, 110)
black = (20, 20, 20)
y = pad
draw.text((pad, y), (site_title or "Ticket").upper(), fill=grey, font=small_font)
y += 30 + 16
for line in title_lines:
draw.text((pad, y), line, fill=black, font=title_font)
y += 52
y += 12
for line in meta_lines:
draw.text((pad, y), line, fill=black, font=body_font)
y += 36
y += 28
qr = make_qr_png(f"ticket://{ticket.id}", size=qr_size, logo=logo).convert("RGB")
img.paste(qr, ((width - qr_size) // 2, qr_y))
y = qr_y + qr_size + 28
for line in detail_lines:
font = mono_font if line.startswith("Ticket ") else body_font
draw.text((pad, y), line, fill=black, font=font)
y += 36
y += 12
draw.text((pad, y), "Show this QR code at the door.", fill=grey, font=small_font)
return img
def image_png_bytes(img: Image.Image) -> bytes:
out = BytesIO()
img.save(out, format="PNG")
return out.getvalue()

View file

@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import re
import smtplib import smtplib
from asyncio.tasks import create_task from asyncio.tasks import create_task
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid
from html import escape from html import escape
from lnbits.core.models.users import UserNotifications from lnbits.core.models.users import UserNotifications
@ -23,8 +26,22 @@ from .crud import (
update_event, update_event,
update_ticket, update_ticket,
) )
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult from .models import (
Event,
NotificationDeliveryResult,
Ticket,
TicketResendResult,
ensure_ticket_waves,
)
from .nostr_hooks import publish_or_delete_nostr_event from .nostr_hooks import publish_or_delete_nostr_event
from .promo import promo_usage
from .qr import (
format_event_when,
image_png_bytes,
load_qr_logo,
render_ticket_card,
ticket_card_filename,
)
DEFAULT_NOSTR_RELAYS = [ DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io", "wss://relay.damus.io",
@ -59,7 +76,29 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event) event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid" assert event, "Couldn't get event from ticket being paid"
event.sold += 1 event.sold += 1
# Debit the wave the buyer actually bought from. v1.6.8 moved
# inventory onto waves; the event-level counter is only the
# fallback for events that predate them, and is itself a derived
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
# kept — ours decremented unconditionally and could go negative.
ticket_waves = event.extra.ticket_waves or []
if ticket_waves:
selected_wave = next(
(
wave
for wave in ticket_waves
if wave.id == ticket.extra.ticket_wave_id
),
ticket_waves[0],
)
if selected_wave.amount_tickets > 0:
selected_wave.amount_tickets -= 1
elif event.amount_tickets > 0:
event.amount_tickets -= 1 event.amount_tickets -= 1
# Flag inside this same write: the counters and "the relay does
# not know about them yet" land atomically, so a crash between
# here and the publish still leaves the drift discoverable.
event.nostr_publish_pending = True
await update_event(event) await update_event(event)
# Republish the NIP-52 calendar event so connected clients see # Republish the NIP-52 calendar event so connected clients see
@ -72,6 +111,22 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
return ticket return ticket
async def event_promo_usage(event_id: str) -> dict[str, int]:
"""Paid redemptions per promo code for one event (see promo.promo_usage)."""
return promo_usage(await get_event_tickets(event_id))
async def hydrate_promo_usage(event: Event) -> Event:
"""Fill `used_count` on each of the event's promo codes. No query when
the event has no codes, so listing stays cheap."""
if not event.extra.promo_codes:
return event
usage = await event_promo_usage(event.id)
for promo in event.extra.promo_codes:
promo.used_count = usage.get(promo.code, 0)
return event
def send_ticket_notification_in_background(ticket: Ticket) -> None: def send_ticket_notification_in_background(ticket: Ticket) -> None:
create_task(_send_ticket_notification(ticket)) create_task(_send_ticket_notification(ticket))
@ -85,10 +140,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
await _deliver_ticket_notifications(ticket, event) await _deliver_ticket_notifications(ticket, event)
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult: async def resend_ticket_email_notification(
ticket: Ticket, base_url: str | None = None
) -> TicketResendResult:
"""Organizer-triggered re-delivery of the ticket email. Bypasses the """Organizer-triggered re-delivery of the ticket email. Bypasses the
per-event `email_notifications` opt-in (the organizer asked explicitly) per-event `email_notifications` opt-in (the organizer asked explicitly)
but still needs the instance mailer and an address on the ticket.""" but still needs the instance mailer and an address on the ticket.
`base_url` is upstream v1.6.8's: it re-points the ticket link at the
caller's frontend, which matters for us specifically because our
`ticket_base_url` can differ from `lnbits_baseurl` (separate web app).
"""
event = await get_event(ticket.event) event = await get_event(ticket.event)
if not event: if not event:
raise ValueError("Event does not exist.") raise ValueError("Event does not exist.")
@ -96,7 +158,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult
raise ValueError("Email notifications are not enabled.") raise ValueError("Email notifications are not enabled.")
if not ticket.email: if not ticket.email:
raise ValueError("Ticket does not have an email address.") raise ValueError("Ticket does not have an email address.")
if base_url:
ticket.extra.ticket_base_url = base_url.rstrip("/")
# email-only: this is the *email* resend endpoint. Upstream calls
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
# our fork would also fire a Nostr DM the organiser did not ask for.
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False) return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
@ -114,25 +181,77 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
return subject, f"{body}\n\nOpen it here: {ticket_url}" return subject, f"{body}\n\nOpen it here: {ticket_url}"
def _ticket_delivery_message(ticket: Ticket, base_message: str) -> str: def _ticket_details(ticket: Ticket, event: Event) -> str:
return f"{base_message}\n\nTicket image: {_ticket_image_url(ticket)}" """Human-readable ticket facts for the email body. Also what keeps the
mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY)."""
lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"]
if event.location:
lines.append(f"Where: {event.location}")
if ticket.name:
lines.append(f"Name on ticket: {ticket.name}")
lines.append(f"Ticket ID: {ticket.id}")
lines.append(
"Your ticket (with its QR code) is attached to this email — save it "
"or open the link above on your phone, and show the QR code at the "
"door to be scanned in."
)
return "\n".join(lines)
def _ticket_email_html_message(ticket: Ticket, base_message: str) -> str: def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
text_message = _ticket_delivery_message(ticket, base_message) """Text part of the ticket mail.
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
image_url = escape(_ticket_image_url(ticket), quote=True) Carries up to two images, which are NOT the same thing (see the note on
`_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always
present, and the organiser's uploaded template, only when the buyer's
wave opted into it. They had the same label before the v1.6.8 merge
because only one of them existed; now that both can appear the labels
have to distinguish them.
"""
message = (
f"{base_message}\n\n{_ticket_details(ticket, event)}"
f"\n\nTicket card: {_ticket_card_url(ticket)}"
)
ticket_image_url = _ticket_image_url(ticket, event)
if ticket_image_url:
message = f"{message}\n\nTicket image: {ticket_image_url}"
return message
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
"""HTML twin of the text part.
Deliberately no <img> for our own ticket card: it travels as an
attachment (renders inline in most clients, works offline, and keeps
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
The organiser's uploaded ticket image is a remote URL with no attachment
to fall back on, so that one does get upstream's <img> — the surrounding
ticket details keep the mail from being image-only either way.
"""
text_message = _ticket_delivery_message(ticket, event, base_message)
html = escape(text_message)
html = re.sub(
r"(https?://[^\s<]+)",
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
html,
)
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>"
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return html_message
return ( return (
f"{html_message}" f"{html_message}"
f'<p><img src="{image_url}" alt="Ticket QR code" ' f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
'style="max-width: 300px; height: auto;" /></p>' 'style="max-width: 200px; height: auto;" /></p>'
) )
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
subject, base_message = _ticket_notification_message(ticket, event) subject, base_message = _ticket_notification_message(ticket, event)
text_message = _ticket_delivery_message(ticket, base_message) text_message = _ticket_delivery_message(ticket, event, base_message)
html_message = _ticket_email_html_message(ticket, base_message) html_message = _ticket_email_html_message(ticket, event, base_message)
return subject, text_message, html_message return subject, text_message, html_message
@ -145,7 +264,16 @@ async def _deliver_ticket_notifications(
) -> TicketResendResult: ) -> TicketResendResult:
"""Send the ticket by every configured channel and report per-channel """Send the ticket by every configured channel and report per-channel
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
opt-ins when not None; the instance-level prerequisites always apply.""" opt-ins when not None; the instance-level prerequisites always apply.
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
check that errors with "Only NIP-05 Nostr identifiers are supported."
That guard is NOT taken here: it encodes upstream's limitation, not ours.
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
would silently refuse identifiers we deliver to today — and say so with
a message that would be false for this fork.
"""
subject, text_message, html_message = _ticket_notification_payload(ticket, event) subject, text_message, html_message = _ticket_notification_payload(ticket, event)
updated = False updated = False
@ -172,8 +300,20 @@ async def _deliver_ticket_notifications(
if result.email.attempted: if result.email.attempted:
try: try:
assert ticket.email assert ticket.email
card = render_ticket_card(
ticket,
event,
logo=await load_qr_logo(),
site_title=settings.lnbits_site_title,
)
await _send_ticket_email_notification( await _send_ticket_email_notification(
[ticket.email], text_message, subject, html_message [ticket.email],
text_message,
subject,
html_message,
attachments=[
(ticket_card_filename(ticket, event), image_png_bytes(card))
],
) )
ticket.extra.email_notification_sent = True ticket.extra.email_notification_sent = True
result.email.sent = True result.email.sent = True
@ -204,6 +344,7 @@ async def _send_ticket_email_notification(
message: str, message: str,
subject: str, subject: str,
html_message: str | None = None, html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None,
) -> None: ) -> None:
"""Multipart (text + HTML) ticket email through the instance SMTP """Multipart (text + HTML) ticket email through the instance SMTP
settings. Core's `send_email_notification` is plain-text only, which is settings. Core's `send_email_notification` is plain-text only, which is
@ -221,14 +362,9 @@ async def _send_ticket_email_notification(
if not is_valid_email_address(address): if not is_valid_email_address(address):
raise ValueError(f"Invalid email address: {address}") raise ValueError(f"Invalid email address: {address}")
msg = MIMEMultipart("alternative") msg = build_ticket_email(
msg["From"] = from_email from_email, to_emails, subject, message, html_message, attachments
msg["To"] = ", ".join(to_emails) )
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
if html_message:
msg.attach(MIMEText(html_message, "html"))
username = settings.lnbits_email_notifications_username or from_email username = settings.lnbits_email_notifications_username or from_email
await asyncio.to_thread( await asyncio.to_thread(
_smtp_send, _smtp_send,
@ -242,6 +378,43 @@ async def _send_ticket_email_notification(
) )
def build_ticket_email(
from_email: str,
to_emails: list[str],
subject: str,
message: str,
html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None,
) -> MIMEMultipart:
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
(the ticket card), and the headers receivers score on — a Date and a
Message-ID (their absence is what SpamAssassin's MISSING_DATE /
MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and
a display name on From so the sender is not a bare address."""
body = MIMEMultipart("alternative")
body.attach(MIMEText(message, "plain"))
if html_message:
body.attach(MIMEText(html_message, "html"))
if attachments:
msg = MIMEMultipart("mixed")
msg.attach(body)
for filename, data in attachments:
part = MIMEImage(data, _subtype="png")
part.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(part)
else:
msg = body
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
msg["From"] = formataddr((sender_name, from_email))
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
return msg
def _smtp_send( def _smtp_send(
server: str, server: str,
port: int, port: int,
@ -276,12 +449,34 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}" return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_image_url(ticket: Ticket) -> str: def _ticket_card_url(ticket: Ticket) -> str:
"""The QR PNG is served by THIS extension on the LNbits host, so it is """Our rendered ticket-card PNG — always available, and served by THIS
built from `lnbits_baseurl` even when `ticket_base_url` points at a extension on the LNbits host, so it is built from `lnbits_baseurl` even
separate web app (deviation from upstream, which assumes both are the when `ticket_base_url` points at a separate web app (deviation from
same host).""" upstream, which assumes both are the same host)."""
return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}" return (
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
)
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
"""Upstream's organiser-uploaded ticket template, opted into per wave.
Distinct from `_ticket_card_url`: that is our own rendered card and is
always attached, this is a template the organiser uploads and enables
on a specific wave. They shared a name before the v1.6.8 merge, which
made them look like one feature.
"""
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
return None
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/events/api/v1/qr/{ticket.id}"
async def refund_tickets(event_id: str): async def refund_tickets(event_id: str):

Binary file not shown.

BIN
static/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View file

@ -0,0 +1,187 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$

BIN
static/image/ticket.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -13,6 +13,7 @@ window.PageEventsDisplay = {
email: '', email: '',
refund: '', refund: '',
nostr_identifier: '', nostr_identifier: '',
ticket_wave_id: null,
payment_method: 'lightning' payment_method: 'lightning'
} }
}, },
@ -46,26 +47,62 @@ window.PageEventsDisplay = {
paymentMethods() { paymentMethods() {
// Mirrors `effective_payment_methods` on the backend: an explicit // Mirrors `effective_payment_methods` on the backend: an explicit
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat. // extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
// is only the PRIMARY wave's, so prefer the active wave when there is
// one — otherwise a later fiat-enabled wave would not offer fiat.
const explicit = this.event?.extra?.payment_methods || [] const explicit = this.event?.extra?.payment_methods || []
if (explicit.length) return explicit if (explicit.length) return explicit
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])] const allowFiat = this.selectedTicketWave
? this.selectedTicketWave.allow_fiat
: this.event?.allow_fiat
return ['lightning', ...(allowFiat ? ['fiat'] : [])]
},
activeTicketWaves() {
const today = new Date().toISOString().slice(0, 10)
return (this.event?.extra?.ticket_waves || []).filter(
wave =>
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
},
selectedTicketWave() {
return (
this.activeTicketWaves.find(
wave => wave.id === this.formDialog.data.ticket_wave_id
) ||
this.activeTicketWaves[0] ||
null
)
},
showTicketWaveSelector() {
return this.activeTicketWaves.length > 1
}, },
allowFiatCheckout() { allowFiatCheckout() {
return this.paymentMethods.includes('fiat') return this.paymentMethods.includes('fiat')
}, },
paymentMethodOptions() { paymentMethodOptions() {
// Options come from `paymentMethods` — the same list the backend
// validates against in `effective_payment_methods` — rather than
// being re-derived from per-method booleans, so a rail the server
// would reject can never be offered. The `|| method` fallback covers
// a method with no label yet (on-chain, once #41 lands).
const labels = {
lightning: 'Lightning',
fiat: this.fiatCheckoutLabel
}
return this.paymentMethods.map(method => ({ return this.paymentMethods.map(method => ({
value: method, value: method,
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning' label: labels[method] || method
})) }))
}, },
fiatCheckoutLabel() { fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat' if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes( const unit = ['sat', 'sats'].includes(
(this.event?.currency || '').toLowerCase() (this.selectedTicketWave?.currency || '').toLowerCase()
) )
? this.event?.fiat_currency ? this.selectedTicketWave?.fiat_currency
: this.event?.currency : this.selectedTicketWave?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})` return `Fiat (${(unit || 'GBP').toUpperCase()})`
}, },
allowEmailNotifications() { allowEmailNotifications() {
@ -82,6 +119,16 @@ window.PageEventsDisplay = {
'GET', 'GET',
`/events/api/v1/events/${this.eventId}` `/events/api/v1/events/${this.eventId}`
) )
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
const today = new Date().toISOString().slice(0, 10)
return (
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
})
this.formDialog.data.ticket_wave_id =
activeWaves.length === 1 ? activeWaves[0].id : null
return data return data
} catch (error) { } catch (error) {
this.eventErrorLabel = 'Event unavailable.' this.eventErrorLabel = 'Event unavailable.'
@ -94,6 +141,11 @@ window.PageEventsDisplay = {
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = '' this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = '' this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.promo_code = ''
this.formDialog.data.payment_method = this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning' this.paymentMethods[0] || 'lightning'
}, },
@ -107,6 +159,13 @@ window.PageEventsDisplay = {
this.paymentWebsocket.close() this.paymentWebsocket.close()
this.paymentWebsocket = null this.paymentWebsocket = null
} }
this.paymentReq = null
this.receive = {
show: false,
status: 'pending',
paymentReq: null,
isFiat: false
}
}, },
nameValidation(val) { nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -129,6 +188,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = '' this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = '' this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.payment_method = this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning' this.paymentMethods[0] || 'lightning'
Quasar.Notify.create({ Quasar.Notify.create({
@ -159,10 +222,19 @@ window.PageEventsDisplay = {
{ {
name: this.formDialog.data.name, name: this.formDialog.data.name,
email: this.formDialog.data.email, email: this.formDialog.data.email,
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
promo_code: this.formDialog.data.promo_code || null, promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null, refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null, nostr_identifier: this.formDialog.data.nostr_identifier || null,
payment_method: this.formDialog.data.payment_method // Gate matches the template's (`paymentMethods.length > 1`).
// v1.6.8 gated on `showPaymentMethodSelector`
// (`allowFiatCheckout || allowOnchain`), a different condition:
// where the two disagreed the buyer's visible choice was
// silently replaced with 'lightning'.
payment_method:
this.paymentMethods.length > 1
? this.formDialog.data.payment_method
: this.paymentMethods[0] || 'lightning'
} }
) )
const isFiat = Boolean(data.is_fiat) const isFiat = Boolean(data.is_fiat)
@ -196,7 +268,7 @@ window.PageEventsDisplay = {
const url = new URL(window.location) const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/api/v1/ws/${paymentHash}` url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
url.search = '' url.search = ''
url.hash = '' url.hash = ''
@ -205,7 +277,7 @@ window.PageEventsDisplay = {
ws.onmessage = event => { ws.onmessage = event => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.pending === false) { if (data.paid === true) {
this.paymentSuccess(paymentHash) this.paymentSuccess(paymentHash)
ws.close() ws.close()
} }
@ -214,8 +286,12 @@ window.PageEventsDisplay = {
console.error('WebSocket error:', error) console.error('WebSocket error:', error)
} }
ws.onclose = () => { ws.onclose = () => {
if (this.paymentWebsocket === ws) { if (this.paymentWebsocket !== ws) return
this.paymentWebsocket = null this.paymentWebsocket = null
if (this.receive.show) {
setTimeout(() => {
if (this.receive.show) this.paymentWatcher(paymentHash)
}, 3000)
} }
} }
} }

View file

@ -74,7 +74,7 @@
filled filled
dense dense
v-model.trim="formDialog.data.nostr_identifier" v-model.trim="formDialog.data.nostr_identifier"
label="(optional) Nostr NIP-05 or npub" label="(optional) Nostr NIP-05"
hint="If provided, we'll DM your ticket link after payment." hint="If provided, we'll DM your ticket link after payment."
></q-input> ></q-input>
</div> </div>
@ -89,6 +89,21 @@
lazy-rules lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input> ></q-input>
<q-select
v-if="showTicketWaveSelector"
filled
dense
v-model="formDialog.data.ticket_wave_id"
emit-value
map-options
label="Ticket wave"
:options="
activeTicketWaves.map(wave => ({
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
value: wave.id
}))
"
></q-select>
<div class="row q-col-gutter-md q-pt-lg items-center"> <div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="paymentMethods.length > 1" class="col-auto"> <div v-if="paymentMethods.length > 1" class="col-auto">
<q-option-group <q-option-group
@ -117,7 +132,8 @@
:disable=" :disable="
formDialog.data.name == '' || formDialog.data.name == '' ||
formDialog.data.email == '' || formDialog.data.email == '' ||
Boolean(paymentReq) (showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
(receive.show && Boolean(paymentReq))
" "
type="submit" type="submit"
>Submit</q-btn >Submit</q-btn

View file

@ -4,7 +4,10 @@ window.PageEvents = {
return { return {
events: [], events: [],
tickets: [], tickets: [],
allPaidTickets: [],
resendingTicketEmails: [], resendingTicketEmails: [],
isUploadingTicketTemplate: false,
ticketImageUploadTarget: null,
currencies: [], currencies: [],
pendingEvents: [], pendingEvents: [],
allUserEvents: [], allUserEvents: [],
@ -25,7 +28,12 @@ window.PageEvents = {
label: 'Owner', label: 'Owner',
field: 'wallet_user_id' field: 'wallet_user_id'
}, },
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {
name: 'id',
align: 'left',
label: 'ID',
field: row => this.shortenId(row.id)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{ {
name: 'event_start_date', name: 'event_start_date',
@ -39,12 +47,6 @@ window.PageEvents = {
label: 'End date', label: 'End date',
field: 'event_end_date' field: 'event_end_date'
}, },
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{ {
name: 'canceled', name: 'canceled',
align: 'left', align: 'left',
@ -67,13 +69,15 @@ window.PageEvents = {
name: 'event_start_date', name: 'event_start_date',
align: 'left', align: 'left',
label: 'Start date', label: 'Start date',
field: 'event_start_date' field: 'event_start_date',
format: val => this.formatEventDate(val)
}, },
{ {
name: 'event_end_date', name: 'event_end_date',
align: 'left', align: 'left',
label: 'End date', label: 'End date',
field: 'event_end_date' field: 'event_end_date',
format: val => this.formatEventDate(val)
}, },
{ {
name: 'closing_date', name: 'closing_date',
@ -127,16 +131,41 @@ window.PageEvents = {
} }
}, },
ticketsTable: { ticketsTable: {
loading: false,
columns: [ columns: [
{name: 'event', align: 'left', label: 'Event', field: 'event'}, {
name: 'event',
align: 'left',
label: 'Event',
field: row => this.shortenId(row.event)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'}, {name: 'email', align: 'left', label: 'Email', field: 'email'},
{
name: 'email_sent',
align: 'left',
label: 'Email sent',
field: row =>
!row.email
? 'no email'
: row.extra?.email_notification_sent
? '\u2713 sent'
: row.paid
? 'not sent'
: 'unpaid'
},
{ {
name: 'registered', name: 'registered',
align: 'left', align: 'left',
label: 'Registered', label: 'Registered',
field: 'registered' field: 'registered'
}, },
{
name: 'nostr',
align: 'left',
label: 'Nostr',
field: row => row.extra?.nostr_identifier || ''
},
{ {
name: 'promo_code', name: 'promo_code',
align: 'left', align: 'left',
@ -146,39 +175,302 @@ window.PageEvents = {
{name: 'id', align: 'left', label: 'ID', field: 'id'} {name: 'id', align: 'left', label: 'ID', field: 'id'}
], ],
pagination: { pagination: {
rowsPerPage: 10 sortBy: 'time',
descending: true,
page: 1,
rowsPerPage: 10,
rowsNumber: 10
} }
}, },
// Rails an organizer can enable per event. Mirrors the webapp's
// CreateEventDialog; `fiat` is rendered disabled when the LNbits user
// has no fiat provider (see `hasFiatProvider`).
paymentMethodOptions: [
{
value: 'lightning',
label: 'Lightning',
hint: 'Pay with any Lightning wallet'
},
{
value: 'fiat',
label: 'Card',
hint: 'Card or bank through your configured fiat provider'
}
],
// Same list the webapp offers (src/modules/events/types/category.ts);
// published as NIP-52 `t` tags so both clients filter on one vocabulary.
categoryOptions: [
'concert',
'workshop',
'market',
'festival',
'exhibition',
'sport',
'theater',
'cinema',
'party',
'talk',
'conference',
'meetup',
'food',
'outdoor',
'kids',
'wellness',
'technology',
'art',
'music',
'dance',
'literature',
'comedy',
'charity',
'tradition',
'other'
].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
formDialog: { formDialog: {
show: false, show: false,
data: { data: {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
ticket_waves: [],
promo_codes: [], promo_codes: [],
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
} }
} }
},
ticketWaveDialog: {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
} }
},
promoCodesDialog: {
show: false,
data: {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
}
}
},
computed: {
hasFiatProvider() {
return (this.g.user?.fiat_providers || []).length > 0
},
fiatProviderNames() {
return (this.g.user?.fiat_providers || [])
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join(', ')
},
acceptsFiat() {
return (this.formDialog.data.extra?.payment_methods || []).includes(
'fiat'
)
},
isSatPrice() {
return !this.isFiatCurrency(this.formDialog.data.currency)
},
fiatCurrencyOptions() {
return this.currencies.filter(c => this.isFiatCurrency(c))
} }
}, },
methods: { methods: {
shortenId(value) {
if (!value) return ''
return value.length > 4 ? `${value.slice(0, 4)}...` : value
},
primaryTicketWave(data = this.formDialog.data) {
if (!data.extra) data.extra = {}
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
data.extra.ticket_waves = [
{
id: 'primary',
title: 'Primary wave',
opening_date: data.closing_date || '',
closing_date: data.closing_date || '',
currency: data.currency || 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: Boolean(data.allow_fiat),
fiat_currency: data.fiat_currency || 'GBP',
amount_tickets: data.amount_tickets || 0,
price_per_ticket: data.price_per_ticket || 0
}
]
}
return data.extra.ticket_waves[0]
},
syncPrimaryWaveFromForm(data = this.formDialog.data) {
const primaryWave = this.primaryTicketWave(data)
primaryWave.title = primaryWave.title || 'Primary wave'
primaryWave.opening_date = primaryWave.opening_date || ''
primaryWave.closing_date = data.closing_date || ''
primaryWave.currency = data.currency || 'sats'
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
primaryWave.allow_fiat = Boolean(data.allow_fiat)
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
return primaryWave
},
hydrateEventForm(data) {
const formData = {
...data,
extra: {
...(data.extra || {}),
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
}
}
const primaryWave = this.primaryTicketWave(formData)
formData.currency = primaryWave.currency || formData.currency || 'sats'
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
formData.amount_tickets = primaryWave.amount_tickets
formData.price_per_ticket = primaryWave.price_per_ticket
formData.closing_date =
primaryWave.closing_date || formData.closing_date || ''
return formData
},
isFiatCurrency(currency) { isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase()) return !['sat', 'sats'].includes((currency || '').toLowerCase())
}, },
getTickets() { normalizePromoCodes(promoCodes = []) {
LNbits.api return promoCodes
.request( .filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase(),
// fork-only: blank / 0 = unlimited; used_count is derived server-side
max_uses: code.max_uses ? Number(code.max_uses) : null
}))
},
templateDownloadUrl() {
return '/events/static/image/ticket.jpg'
},
async uploadAssetFile(file) {
const form = new FormData()
form.append('file', file)
form.append('public_asset', 'true')
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
form
)
return data.id
},
triggerTicketImageUpload(target) {
this.ticketImageUploadTarget = target
this.$refs.ticketImageUpload.value = null
this.$refs.ticketImageUpload.click()
},
async handleTicketImageSelected(event) {
const file = event.target.files?.[0]
if (!file || !this.ticketImageUploadTarget) return
this.isUploadingTicketTemplate = true
try {
const assetId = await this.uploadAssetFile(file)
if (this.ticketImageUploadTarget === 'primary') {
const wave = this.primaryTicketWave()
wave.use_ticket_image = true
wave.ticket_image_id = assetId
} else if (this.ticketImageUploadTarget === 'dialog') {
this.ticketWaveDialog.data.use_ticket_image = true
this.ticketWaveDialog.data.ticket_image_id = assetId
}
Quasar.Notify.create({
type: 'positive',
message: 'Ticket template uploaded.',
icon: null
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.isUploadingTicketTemplate = false
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 =>
ticket.event === eventId &&
ticket.paid &&
(ticket.extra?.ticket_wave_id === waveId ||
(!ticket.extra?.ticket_wave_id && waveId === 'primary'))
).length
},
async getAllTickets() {
try {
const {data} = await LNbits.api.request(
'GET', 'GET',
'/events/api/v1/tickets?all_wallets=true', '/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
.then(response => { this.allPaidTickets = data.filter(ticket => ticket.paid)
this.tickets = response.data.filter(e => e.paid) } catch (error) {
}) LNbits.utils.notifyApiError(error)
}
},
async getTickets(props) {
try {
this.ticketsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
this.g.user.wallets[0].adminkey
)
this.tickets = data.data
this.ticketsTable.pagination.rowsNumber = data.total
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.ticketsTable.loading = false
}
}, },
deleteTicket(ticketId) { deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId}) const tickets = _.findWhere(this.tickets, {id: ticketId})
@ -193,10 +485,9 @@ window.PageEvents = {
'/events/api/v1/tickets/' + ticketId, '/events/api/v1/tickets/' + ticketId,
wallet.adminkey wallet.adminkey
) )
.then(response => { .then(async () => {
this.tickets = _.reject(this.tickets, function (obj) { await this.getTickets()
return obj.id == ticketId await this.getAllTickets()
})
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}) })
@ -214,14 +505,30 @@ window.PageEvents = {
wallet.adminkey wallet.adminkey
) )
.then(response => { .then(response => {
const result = response.data
this.tickets = this.tickets.map(obj => this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? response.data : obj obj.id === ticket.id ? result.ticket : obj
) )
if (result.email?.attempted) {
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: result.email.sent ? 'positive' : 'negative',
message: 'Ticket email resent.', message: result.email.sent
? 'Ticket email resent.'
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
icon: null icon: null
}) })
}
if (result.nostr?.attempted) {
Quasar.Notify.create({
type: result.nostr.sent ? 'positive' : 'negative',
message: result.nostr.sent
? 'Ticket Nostr DM resent.'
: `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`,
icon: null
})
}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
.finally(() => { .finally(() => {
@ -231,7 +538,7 @@ window.PageEvents = {
}) })
}, },
exportticketsCSV() { exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
}, },
getEvents() { getEvents() {
LNbits.api LNbits.api
@ -382,37 +689,70 @@ window.PageEvents = {
}, },
splitDateTime(value) { splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the // Inverse of foldDateTime: split a stored string back into the
// day/time pieces the form inputs bind to. // day/time pieces the form inputs bind to. Slicing to HH:MM also
// drops the seconds + offset suffix withLocalTzOffset stamps on
// submit, so the organizer sees the wall-clock they entered.
if (!value) return {day: '', time: ''} if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T') const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored. // Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)} return {day, time: time.slice(0, 5)}
}, },
withLocalTzOffset(value) {
// Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
// The publisher's `_to_unix` treats a naive datetime as UTC, so an
// event entered as 18:00 in CEST would otherwise go out on Nostr
// as 18:00 UTC. Same transform the webapp applies; date-only
// values pass through unchanged (they map to NIP-52 kind 31922).
if (!value || !value.includes('T')) return value
const offMin = -new Date(value).getTimezoneOffset()
const sign = offMin >= 0 ? '+' : '-'
const abs = Math.abs(offMin)
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
const mm = String(abs % 60).padStart(2, '0')
return `${value}:00${sign}${hh}:${mm}`
},
formatEventDate(value) {
// Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
if (!value) return ''
const {day, time} = this.splitDateTime(value)
return time ? `${day} ${time}` : day
},
validateEndDate() {
// Cross-field rule for the end-day input: end >= start, compared
// on the folded date+time so an equal-day earlier time is caught.
const d = this.formDialog.data
const start = this.foldDateTime(d.event_start_day, d.event_start_time)
const end = this.foldDateTime(d.event_end_day, d.event_end_time)
if (!start || !end) return true
return end >= start || 'End must be on or after start'
},
sendEventData() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = {...this.formDialog.data} const data = {...this.formDialog.data}
data.event_start_date = this.foldDateTime( data.event_start_date = this.withLocalTzOffset(
data.event_start_day, this.foldDateTime(data.event_start_day, data.event_start_time)
data.event_start_time
) )
data.event_end_date = this.foldDateTime( data.event_end_date = this.withLocalTzOffset(
data.event_end_day, this.foldDateTime(data.event_end_day, data.event_end_time)
data.event_end_time
) )
delete data.event_start_day delete data.event_start_day
delete data.event_start_time delete data.event_start_time
delete data.event_end_day delete data.event_end_day
delete data.event_end_time delete data.event_end_time
// Optional NIP-52 fields: blank location is "unset", not "".
data.location = (data.location || '').trim() || null
data.categories = data.categories || []
data.closing_date = data.closing_date || null
// Fold the form's day+time pairs first, then let upstream's helper
// mirror the form fields onto the primary wave — order matters, the
// sync reads closing_date/currency/amount_tickets off `data`.
this.syncPrimaryWaveFromForm(data)
if (data.extra?.promo_codes) { if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = this.normalizePromoCodes(
.filter(code => code.code?.trim() !== '') data.extra.promo_codes
.map(code => ({ )
...code,
code: code.code.trim().toUpperCase()
}))
} }
const methods = data.extra?.payment_methods || [] const methods = data.extra?.payment_methods || []
if (methods.length === 0) { if (methods.length === 0) {
@ -425,11 +765,14 @@ window.PageEvents = {
// allow_fiat stays the fiat-currency carrier the backend and the // allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list. // NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat') data.allow_fiat = methods.includes('fiat')
if (!this.isFiatCurrency(data.currency)) { if (this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) { // A fiat-priced event settles in its price currency; mirror it so
// the payload (and the tickets_fiat_currency tag) stay coherent.
data.fiat_currency = data.currency
} else if (!data.allow_fiat) {
data.fiat_currency = 'GBP' data.fiat_currency = 'GBP'
} }
} this.syncPrimaryWaveFromForm(data)
if (data.id) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -442,10 +785,14 @@ window.PageEvents = {
if (data && data.id) { if (data && data.id) {
const start = this.splitDateTime(data.event_start_date) const start = this.splitDateTime(data.event_start_date)
const end = this.splitDateTime(data.event_end_date) const end = this.splitDateTime(data.event_end_date)
// Seed from upstream's hydrator (it materialises ticket_waves and
// mirrors the primary wave onto the flat form fields), then layer
// our fork-only fields on top.
const hydrated = this.hydrateEventForm(data)
this.formDialog.data = { this.formDialog.data = {
...data, ...hydrated,
extra: { extra: {
...(data.extra || {}), ...(hydrated.extra || {}),
// Events created before extra.payment_methods existed carry an // Events created before extra.payment_methods existed carry an
// empty list; show the rails the backend actually accepts for // empty list; show the rails the backend actually accepts for
// them (Lightning always, fiat when allow_fiat). // them (Lightning always, fiat when allow_fiat).
@ -453,6 +800,8 @@ window.PageEvents = {
? data.extra.payment_methods ? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
}, },
location: data.location || '',
categories: [...(data.categories || [])],
event_start_day: start.day, event_start_day: start.day,
event_start_time: start.time, event_start_time: start.time,
event_end_day: end.day, event_end_day: end.day,
@ -463,6 +812,8 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
event_start_day: '', event_start_day: '',
event_start_time: '', event_start_time: '',
event_end_day: '', event_end_day: '',
@ -473,6 +824,21 @@ window.PageEvents = {
min_tickets: 1, min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [], promo_codes: [],
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
@ -487,10 +853,29 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [], promo_codes: [],
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
@ -511,6 +896,183 @@ window.PageEvents = {
const link = _.findWhere(this.events, {id: formId}) const link = _.findWhere(this.events, {id: formId})
this.openEventDialog(link) this.openEventDialog(link)
}, },
openTicketWaveDialog(event, wave = null) {
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
const isEditing = Boolean(wave)
this.ticketWaveDialog = {
show: true,
eventId: event.id,
wallet: event.wallet,
editingWaveId: wave?.id || null,
data: {
id: wave?.id || null,
title: wave?.title || '',
opening_date: wave?.opening_date || '',
closing_date: wave?.closing_date || '',
currency:
wave?.currency || primaryWave.currency || event.currency || 'sats',
use_ticket_image: Boolean(wave?.use_ticket_image),
ticket_image_id: wave?.ticket_image_id || null,
allow_fiat: isEditing
? Boolean(wave?.allow_fiat)
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
fiat_currency:
wave?.fiat_currency ||
primaryWave.fiat_currency ||
event.fiat_currency ||
'GBP',
// `??` not `||`: a sold-out wave legitimately holds 0 and must
// show it rather than silently regaining stock on save. A NEW
// wave opens at 1, the smallest capacity the backend accepts —
// there is no unlimited (#34).
amount_tickets: isEditing ? (wave?.amount_tickets ?? 0) : 1,
price_per_ticket:
wave?.price_per_ticket ||
primaryWave.price_per_ticket ||
event.price_per_ticket ||
0
}
}
},
resetTicketWaveDialog() {
this.ticketWaveDialog = {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
}
},
saveTicketWave() {
const event = _.findWhere(this.events, {
id: this.ticketWaveDialog.eventId
})
const wallet = _.findWhere(this.g.user.wallets, {
id: this.ticketWaveDialog.wallet
})
if (!event || !wallet) return
const payload = {
...event,
extra: {
...event.extra,
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
existingWave.id === this.ticketWaveDialog.editingWaveId
? {...this.ticketWaveDialog.data}
: existingWave
)
}
}
if (!this.ticketWaveDialog.editingWaveId) {
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
}
if (payload.extra?.promo_codes) {
payload.extra.promo_codes = this.normalizePromoCodes(
payload.extra.promo_codes
)
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + payload.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(item =>
item.id === payload.id ? response.data : item
)
Quasar.Notify.create({
type: 'positive',
message: this.ticketWaveDialog.editingWaveId
? 'Ticket wave updated.'
: 'Ticket wave added.',
icon: null
})
this.resetTicketWaveDialog()
})
.catch(LNbits.utils.notifyApiError)
},
openPromoCodesDialog(event) {
this.promoCodesDialog.data = {
...event,
extra: {
...event.extra,
promo_codes: [...(event.extra?.promo_codes || [])]
}
}
this.promoCodesDialog.show = true
},
resetPromoCodesDialog() {
this.promoCodesDialog.show = false
this.promoCodesDialog.data = {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
},
addPromoCodeToDialog() {
this.promoCodesDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true,
// fork-only: null = unlimited uses
max_uses: null
})
},
savePromoCodes() {
const data = this.promoCodesDialog.data
const wallet = _.findWhere(this.g.user.wallets, {
id: data.wallet
})
if (!wallet) return
const payload = {
...data,
extra: {
...data.extra,
promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || [])
}
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + data.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(event =>
event.id === data.id ? response.data : event
)
Quasar.Notify.create({
type: 'positive',
message: 'Promo codes updated.',
icon: null
})
this.resetPromoCodesDialog()
})
.catch(LNbits.utils.notifyApiError)
},
updateEvent(wallet, data) { updateEvent(wallet, data) {
LNbits.api LNbits.api
.request( .request(
@ -577,6 +1139,7 @@ window.PageEvents = {
async created() { async created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getAllTickets()
this.getEvents() this.getEvents()
this.getSettings() this.getSettings()
this.getPendingEvents() this.getPendingEvents()

View file

@ -243,44 +243,85 @@
<q-tr v-show="props.expand" :props="props"> <q-tr v-show="props.expand" :props="props">
<q-td colspan="100%"> <q-td colspan="100%">
<div class="q-pa-md"> <div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div> <div class="row items-center q-mb-md">
<div class="text-subtitle1">Ticket waves</div>
<q-btn
round
dense
unelevated
color="primary"
icon="add"
class="q-ml-sm"
@click="openTicketWaveDialog(props.row)"
></q-btn>
</div>
<div class="column q-mb-lg">
<div class="column"> <div class="column">
<div <div
v-if="props.row.extra.promo_codes.length == 0" v-for="(wave, index) in props.row.extra.ticket_waves"
:key="wave.id || index"
class="q-mb-sm"
>
<q-chip
square
clickable
class="q-py-xs"
style="height: auto"
@click="openTicketWaveDialog(props.row, wave)"
>
<span
style="white-space: normal; line-height: 1.3"
v-text="waveSummary(props.row.id, wave)"
></span>
</q-chip>
</div>
</div>
</div>
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Promo codes</div>
<q-btn
round
dense
unelevated
color="primary"
icon="edit"
class="q-ml-sm"
@click="openPromoCodesDialog(props.row)"
></q-btn>
</div>
<div class="column">
<div
v-if="
props.row.extra.promo_codes.filter(
code => code.active
).length == 0
"
class="text-caption" class="text-caption"
>
No active promo codes for this event.
</div>
<div class="row q-col-gutter-sm">
<div
v-for="(
code, index
) in props.row.extra.promo_codes.filter(
code => code.active
)"
:key="index"
class="col-auto q-mb-sm"
> >
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip <q-chip
square square
size="md"
clickable clickable
@click="utils.copyText(code.code.toUpperCase())" @click="utils.copyText(code.code.toUpperCase())"
> >
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span <span
:class="code.active ? 'text-green' : 'text-grey'" v-text="
v-text="code.active ? 'Active' : 'Inactive'" `${code.code.toUpperCase()} - ${code.discount_percent}%`
"
></span> ></span>
</q-chip>
</div> </div>
</div> </div>
</div> </div>
@ -308,9 +349,11 @@
dense dense
flat flat
:rows="tickets" :rows="tickets"
:loading="ticketsTable.loading"
row-key="id" row-key="id"
:columns="ticketsTable.columns" :columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination" v-model:pagination="ticketsTable.pagination"
@request="getTickets"
> >
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
@ -479,8 +522,10 @@
filled filled
dense dense
v-model.trim="formDialog.data.name" v-model.trim="formDialog.data.name"
type="name" type="text"
label="Title of event " label="Title of event *"
lazy-rules
:rules="[val => !!val || 'Title is required']"
></q-input> ></q-input>
</div> </div>
<div class="col q-pl-sm"> <div class="col q-pl-sm">
@ -504,6 +549,30 @@
label="Info about the event" label="Info about the event"
hint="Markdown supported" hint="Markdown supported"
></q-input> ></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.location"
type="text"
label="Location"
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
>
<template v-slot:prepend>
<q-icon name="place"></q-icon>
</template>
</q-input>
<q-select
filled
dense
multiple
use-chips
emit-value
map-options
v-model="formDialog.data.categories"
:options="categoryOptions"
label="Categories"
hint="Published as NIP-52 hashtags so clients can filter the feed."
></q-select>
<q-input <q-input
filled filled
dense dense
@ -512,25 +581,16 @@
label="Image URL" label="Image URL"
hint="Optional banner image to display on the event page" hint="Optional banner image to display on the event page"
></q-input> ></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col-4">Event begins</div> <div class="col-4">Event begins *</div>
<div class="col-5"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_day" v-model.trim="formDialog.data.event_start_day"
type="date" type="date"
lazy-rules
:rules="[val => !!val || 'Start date is required']"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -552,6 +612,10 @@
dense dense
v-model.trim="formDialog.data.event_end_day" v-model.trim="formDialog.data.event_end_day"
type="date" type="date"
hint="Defaults to the start date"
lazy-rules
reactive-rules
:rules="[validateEndDate]"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -564,6 +628,51 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm">
<div class="col-4">Ticket sales close</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
hint="Optional. Defaults to the event end date."
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mt-lg q-mb-md">
Primary ticket wave (you can add other waves later)
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -571,7 +680,7 @@
dense dense
v-model="formDialog.data.currency" v-model="formDialog.data.currency"
type="text" type="text"
label="Unit" label="Price currency"
:options="currencies" :options="currencies"
></q-select> ></q-select>
</div> </div>
@ -581,7 +690,9 @@
dense dense
v-model.number="formDialog.data.amount_tickets" v-model.number="formDialog.data.amount_tickets"
type="number" type="number"
label="Amount of tickets " min="1"
label="Amount of tickets"
hint="Total tickets on sale"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">
@ -600,34 +711,100 @@
</div> </div>
</div> </div>
<div class="q-mt-sm"> <div class="q-mt-sm">
<div class="text-caption text-grey-7">Payment methods *</div> <div class="text-subtitle2">Payment methods *</div>
<q-option-group
v-model="formDialog.data.extra.payment_methods"
type="checkbox"
inline
:options="paymentMethodOptions"
></q-option-group>
<div class="text-caption text-grey-7"> <div class="text-caption text-grey-7">
Card / fiat checkout goes through the fiat provider configured on Pick the rails buyers can pay with. Untick Lightning for a
this LNbits instance. Untick Lightning for a card-only event. card-only sale.
</div>
<div class="row q-col-gutter-md q-mt-xs">
<div
v-for="opt in paymentMethodOptions"
:key="opt.value"
class="col-12 col-sm-6"
>
<!-- The span carries the tooltip: a disabled checkbox
swallows pointer events, the wrapper still hovers. -->
<span class="inline-block full-width">
<q-checkbox
v-model="formDialog.data.extra.payment_methods"
:val="opt.value"
:label="opt.label"
:disable="opt.value === 'fiat' && !hasFiatProvider"
></q-checkbox>
<div class="text-caption text-grey-7 q-pl-lg">
<span v-text="opt.hint"></span>
<span
v-if="opt.value === 'fiat' && hasFiatProvider"
v-text="' (' + fiatProviderNames + ')'"
></span>
</div>
<q-tooltip
v-if="opt.value === 'fiat' && !hasFiatProvider"
max-width="280px"
>
Your LNbits user has no fiat provider configured. Ask the
instance admin to enable Stripe, PayPal or Square (Admin →
Fiat providers) to accept card payments.
</q-tooltip>
</span>
</div> </div>
</div> </div>
</div>
<q-toggle
v-model="primaryTicketWave().use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="primaryTicketWave().use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('primary')"
>Replace</q-btn
>
</div>
<div
v-if="primaryTicketWave().ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using the event currency."
></q-toggle>
<q-select <q-select
v-if=" v-if="acceptsFiat && isSatPrice"
acceptsFiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)
"
filled filled
dense dense
v-model="formDialog.data.fiat_currency" v-model="formDialog.data.fiat_currency"
label="Fiat checkout currency" label="Fiat currency"
:options=" hint="Currency card buyers are charged in."
currencies.filter( :options="fiatCurrencyOptions"
c => !['sat', 'sats'].includes((c || '').toLowerCase()) lazy-rules
) :rules="[
" val => !!val || 'Pick a fiat currency for buyers paying by card'
]"
></q-select> ></q-select>
<q-expansion-item <q-expansion-item
group="advanced" group="advanced"
@ -660,95 +837,30 @@
</div> </div>
</div> </div>
<q-separator class="q-my-md"></q-separator> <q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
formDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
})
"
>Add Promo Code</q-btn
>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div> <div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption"> <div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM. Send the paid ticket link automatically by email or Nostr DM.
</div> </div>
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-toggle <q-toggle
v-model="formDialog.data.extra.email_notifications" v-model="formDialog.data.extra.email_notifications"
label="Email notifications" label="Email notifications"
left-label left-label
></q-toggle> ></q-toggle>
</div>
<div class="col-auto">
<q-toggle <q-toggle
v-model="formDialog.data.extra.nostr_notifications" v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications" label="Nostr notifications"
left-label left-label
></q-toggle> ></q-toggle>
</q-expansion-item> </div>
</div>
<q-separator class="q-my-md"></q-separator> <div
v-if="formDialog.data.extra.email_notifications"
class="q-mt-md"
>
<q-input <q-input
filled filled
dense dense
@ -758,6 +870,7 @@
hint="Used as the email subject when sending paid ticket links." hint="Used as the email subject when sending paid ticket links."
></q-input> ></q-input>
<q-input <q-input
class="q-mt-md"
filled filled
dense dense
v-model.trim="formDialog.data.extra.notification_body" v-model.trim="formDialog.data.extra.notification_body"
@ -765,6 +878,8 @@
label="Ticket notification body" label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification." hint="Shown before the ticket link in the paid ticket notification."
></q-input> ></q-input>
</div>
</q-expansion-item>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@ -779,14 +894,11 @@
unelevated unelevated
color="primary" color="primary"
:disable=" :disable="
formDialog.data.wallet == null || !formDialog.data.wallet ||
formDialog.data.name == null || !formDialog.data.name ||
formDialog.data.info == null || !formDialog.data.event_start_day ||
formDialog.data.closing_date == null || primaryTicketWave().title == null ||
formDialog.data.event_start_day == null || primaryTicketWave().opening_date == null
formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
" "
type="submit" type="submit"
>Create Event</q-btn >Create Event</q-btn
@ -798,5 +910,277 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="promoCodesDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="savePromoCodes" class="q-gutter-md">
<div class="text-subtitle1">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in promoCodesDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="promoCodesDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="
promoCodesDialog.data.extra.promo_codes[index].active
"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
promoCodesDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
:hint="
promoCodesDialog.data.extra.promo_codes[index].used_count
? 'Used ' +
promoCodesDialog.data.extra.promo_codes[index].used_count +
(promoCodesDialog.data.extra.promo_codes[index].max_uses
? ' / ' +
promoCodesDialog.data.extra.promo_codes[index].max_uses
: '')
: ''
"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
"
></q-btn>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].max_uses
"
type="number"
label="Max uses"
placeholder="∞"
min="1"
hint="Blank = unlimited"
></q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Save Promo Codes</q-btn
>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetPromoCodesDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="ticketWaveDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="saveTicketWave" class="q-gutter-md">
<div
class="text-subtitle1"
v-text="
ticketWaveDialog.editingWaveId
? 'Edit Ticket Wave'
: 'Add Ticket Wave'
"
></div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="ticketWaveDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.amount_tickets"
type="number"
min="1"
label="Amount of tickets"
hint="Tickets on sale in this wave"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.price_per_ticket"
type="number"
:label="'Price (' + ticketWaveDialog.data.currency + ') *'"
:step="ticketWaveDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="ticketWaveDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="ticketWaveDialog.data.currency == null"
></q-input>
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="ticketWaveDialog.data.use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('dialog')"
>Replace</q-btn
>
</div>
<div
v-if="ticketWaveDialog.data.ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using this wave currency."
></q-toggle>
<q-select
v-if="
ticketWaveDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(ticketWaveDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="ticketWaveDialog.data.fiat_currency"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">{{
ticketWaveDialog.editingWaveId
? 'Update Ticket Wave'
: 'Save Ticket Wave'
}}</q-btn>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetTicketWaveDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<input
ref="ticketImageUpload"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="handleTicketImageSelected"
/>
</div> </div>
</template> </template>

View file

@ -3,16 +3,36 @@ window.PageEventsTicket = {
data() { data() {
return { return {
ticketId: null, ticketId: null,
ticket: null ticket: null,
printMode: false,
qrSrc: ''
} }
}, },
methods: { methods: {
printWindow() { async printWindow() {
window.print() this.printMode = true
await this.$nextTick()
await this.waitForPrintAssets()
setTimeout(() => window.print(), 50)
},
async waitForPrintAssets() {
await this.$nextTick()
const img = document.querySelector('.ticket-print-qr')
if (!img) return
if (img.complete && img.naturalWidth > 0) return
await new Promise(resolve => {
const done = () => resolve()
img.addEventListener('load', done, {once: true})
img.addEventListener('error', done, {once: true})
setTimeout(done, 500)
})
} }
}, },
async created() { async created() {
this.ticketId = this.$route.params.id this.ticketId = this.$route.params.id
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
`ticket://${this.ticketId}`
)}`
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
@ -22,5 +42,8 @@ window.PageEventsTicket = {
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
window.addEventListener('afterprint', () => {
this.printMode = false
})
} }
} }

View file

@ -36,4 +36,53 @@
</q-card> </q-card>
</div> </div>
</div> </div>
<Teleport to="body">
<div class="ticket-print-sheet" v-if="printMode">
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
</div>
</Teleport>
</template> </template>
<style>
@media print {
@page {
size: auto;
margin: 0;
}
html {
font-size: 12px !important;
}
* {
color: black !important;
background: white !important;
box-shadow: none !important;
}
body > * {
display: none !important;
}
.ticket-print-sheet {
display: flex !important;
align-items: center;
justify-content: center;
width: 100vw;
min-height: 100vh;
margin: 0 !important;
padding: 0 !important;
background: white !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.ticket-print-qr {
display: block;
width: 320px;
height: 320px;
object-fit: contain;
}
}
</style>

View file

@ -0,0 +1,112 @@
"""`amount_tickets` is the remaining count (aiolabs/events#34).
`set_ticket_paid` decrements it on every sale and `sold` increments, so
subtracting `sold` from it removes each sale twice. That made the buyer
cap under-report and, once an event passed half its capacity, turned
`sold >= amount_tickets` true and declared it sold out with stock left.
Measured on aio-demo before the fix: 16 of 24 live events affected,
3 already refusing sales while tickets remained.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from .. import views_api
from ..models import CreateTicket, Event
SENTINEL = object()
def _event(amount_tickets: int, sold: int) -> Event:
return Event(
id="evt",
wallet="w",
name="Capacity",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01",
event_end_date="2030-01-02",
currency="sat",
price_per_ticket=0, # free path, so the guard is all that gates us
amount_tickets=amount_tickets,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
)
@pytest.fixture
def issued(monkeypatch):
"""Past the capacity guard the free path short-circuits to a sentinel."""
monkeypatch.setattr(
views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL)
)
monkeypatch.setattr(
views_api, "_resolve_frontend_root", lambda data, req: "http://x"
)
return SENTINEL
async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch):
monkeypatch.setattr(
views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold))
)
return await views_api.api_ticket_create(
"evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace()
)
@pytest.mark.asyncio
async def test_last_ticket_still_sells(monkeypatch, issued):
"""One left, one wanted. Previously refused once sold >= remaining."""
assert await _buy(1, 99, 1, monkeypatch) is issued
@pytest.mark.asyncio
async def test_sold_out_only_when_actually_empty(monkeypatch, issued):
with pytest.raises(HTTPException) as exc:
await _buy(0, 100, 1, monkeypatch)
assert exc.value.detail == "Event is sold out."
@pytest.mark.asyncio
@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500])
async def test_remaining_alone_decides_availability(monkeypatch, issued, sold):
"""The regression proper: with 50 left the event sells, whatever
`sold` says. Because remaining + sold is the original capacity,
`sold >= amount_tickets` first flips true at the halfway point —
sold=50 here — so an event locked itself once half its seats went.
The boundary cases (49/50/51) are the ones that matter."""
assert await _buy(50, sold, 1, monkeypatch) is issued
@pytest.mark.asyncio
async def test_bulk_order_may_take_everything_left(monkeypatch, issued):
assert await _buy(10, 40, 10, monkeypatch) is issued
@pytest.mark.asyncio
async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued):
"""3 left, 5 wanted. The message must name the real remainder — it
used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10
by the model, so the overshoot is tested within that bound.)"""
with pytest.raises(HTTPException) as exc:
await _buy(3, 40, 5, monkeypatch)
assert exc.value.detail == "Only 3 ticket(s) remaining for this event."
@pytest.mark.asyncio
async def test_walk_an_event_to_capacity(monkeypatch, issued):
"""50-seat event, one sale at a time, mirroring set_ticket_paid."""
remaining, sold = 50, 0
for _ in range(50):
assert await _buy(remaining, sold, 1, monkeypatch) is issued
remaining, sold = remaining - 1, sold + 1
assert (remaining, sold) == (0, 50)
with pytest.raises(HTTPException) as exc:
await _buy(remaining, sold, 1, monkeypatch)
assert exc.value.detail == "Event is sold out."

View file

@ -0,0 +1,171 @@
"""The `nostr_publish_pending` marker and its lifecycle.
Inventory reaches clients only through the republished NIP-52 calendar
event. These tests pin the invariant that makes drift recoverable: the
flag goes up before every attempt and comes down only on a confirmed
success, so every shape of failure — raised, skipped, never attempted —
leaves the row queryable by the sweep.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from .. import nostr_hooks, services
from ..models import Event, Ticket
def _event(**kwargs) -> Event:
defaults = {
"id": "evt",
"wallet": "w",
"name": "Test",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01",
"event_end_date": "2030-01-02",
"currency": "sat",
"price_per_ticket": 1000,
"amount_tickets": 10,
"time": datetime.now(timezone.utc),
"status": "approved",
}
defaults.update(kwargs)
return Event(**defaults)
@pytest.fixture
def saved(monkeypatch):
"""Capture every update_event write so ordering can be asserted."""
writes: list[bool] = []
async def _update(event):
writes.append(event.nostr_publish_pending)
return event
monkeypatch.setattr(nostr_hooks, "update_event", _update)
return writes
def _signer(monkeypatch, signer):
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer)
)
def _publisher(monkeypatch, result):
monkeypatch.setattr(
nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result)
)
@pytest.mark.asyncio
async def test_success_raises_then_clears_the_flag(monkeypatch, saved):
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
# Flagged before the attempt, cleared after it — in that order.
assert saved == [True, False]
assert event.nostr_publish_pending is False
assert event.nostr_event_id == "nid"
assert event.nostr_event_created_at == 123
@pytest.mark.asyncio
async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved):
event = _event()
_signer(monkeypatch, None)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved):
"""The no-NostrClient shape: nothing raised, nothing published."""
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, None)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved):
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(side_effect=RuntimeError("signer timeout")),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved):
"""The sweep re-publishing a flagged row writes once, not twice."""
event = _event(nostr_publish_pending=True)
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
assert saved == [False]
@pytest.mark.asyncio
async def test_delete_clears_the_flag_without_touching_the_coordinate(
monkeypatch, saved
):
"""A take-down must not overwrite the id/created_at of the event it
just deleted — the kind-5 has its own."""
event = _event(nostr_event_id="old", nostr_event_created_at=100)
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="del", created_at=999))
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
assert event.nostr_publish_pending is False
assert event.nostr_event_id == "old"
assert event.nostr_event_created_at == 100
@pytest.mark.asyncio
async def test_sale_flags_the_event_in_the_same_write(monkeypatch):
"""`set_ticket_paid` must flag inside its own update, so the counters
and "the relay doesn't know yet" land atomically."""
event = _event(sold=4, amount_tickets=6)
seen: list[tuple[int, int, bool]] = []
async def _update_event(ev):
seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending))
return ev
monkeypatch.setattr(services, "update_ticket", AsyncMock())
monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event))
monkeypatch.setattr(services, "update_event", _update_event)
monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock())
ticket = Ticket(
id="t1",
wallet="w",
event="evt",
name="A",
email="a@example.com",
registered=False,
paid=False,
time=datetime.now(timezone.utc),
reg_timestamp=datetime.now(timezone.utc),
)
await services.set_ticket_paid(ticket)
assert seen == [(5, 5, True)]

218
tests/test_promo.py Normal file
View file

@ -0,0 +1,218 @@
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from ..models import (
Event,
EventExtra,
PromoCode,
PublicEvent,
Ticket,
TicketWave,
ensure_ticket_waves,
)
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
def _event(currency="sat", price=1000.0, codes=None) -> Event:
return Event(
id="evt",
wallet="w",
name="Test",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01",
event_end_date="2030-01-02",
currency=currency,
price_per_ticket=price,
amount_tickets=10,
time=datetime.now(timezone.utc),
extra=EventExtra(promo_codes=codes or []),
)
def _wave(event: Event) -> TicketWave:
"""Primary wave synthesized from the event's own price/currency.
These tests exercise promo arithmetic rather than wave selection, so
pricing against the primary wave keeps their original meaning.
"""
return ensure_ticket_waves(event)[0]
def _ticket(code, paid=True) -> Ticket:
now = datetime.now(timezone.utc)
return Ticket(
id=f"t-{code}-{paid}",
wallet="w",
event="evt",
registered=False,
paid=paid,
time=now,
reg_timestamp=now,
extra={"applied_promo_code": code},
)
# --- model -----------------------------------------------------------------
def test_code_is_stripped_and_uppercased():
assert PromoCode(code=" half ", discount_percent=50).code == "HALF"
def test_empty_code_is_rejected():
with pytest.raises(ValidationError):
PromoCode(code=" ", discount_percent=10)
@pytest.mark.parametrize(
"raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)]
)
def test_max_uses_normalisation(raw, expected):
assert PromoCode(code="X", max_uses=raw).max_uses == expected
def test_max_uses_below_one_rejected():
with pytest.raises(ValidationError):
PromoCode(code="X", max_uses=-1)
def test_discount_bounds():
with pytest.raises(ValidationError):
PromoCode(code="X", discount_percent=101)
def test_public_event_projection_drops_promo_codes():
event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)])
public = PublicEvent.parse_obj(event.dict()).dict()
assert "promo_codes" not in public["extra"]
assert public["extra"]["payment_methods"] == []
# the full model keeps them
assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET"
# --- helpers ---------------------------------------------------------------
def test_normalize_code():
assert normalize_code(" save20 ") == "SAVE20"
assert normalize_code("") is None
assert normalize_code(None) is None
def test_promo_usage_counts_paid_rows_only_per_ticket():
usage = promo_usage(
[_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)]
)
assert usage == {"HALF": 2}
def test_remaining_uses():
assert remaining_uses(PromoCode(code="X"), 5) is None
assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2
assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0
# --- basket_totals -----------------------------------------------------------
def test_sat_totals_round_to_whole_sats():
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
totals = basket_totals(event, ["off15"], 1, {}, _wave(event))
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
assert totals.currency == "sat"
assert totals.discounts_applied[0].dict() == {
"code": "OFF15",
"discount_percent": 15,
"discount_fixed": None,
"amount_saved": 50,
}
def test_fiat_totals_round_to_cents_and_scale_by_quantity():
event = _event(
currency="EUR",
price=19.99,
codes=[PromoCode(code="THIRD", discount_percent=33)],
)
totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event))
assert totals.subtotal == 59.97
assert totals.total == 40.18
assert totals.discount == 19.79
assert totals.discount + totals.total == totals.subtotal
def test_first_applicable_code_wins():
event = _event(
codes=[
PromoCode(code="A", discount_percent=10),
PromoCode(code="B", discount_percent=50),
]
)
assert (
basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event))
.discounts_applied[0]
.code
== "B"
)
def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
event = _event(
codes=[
PromoCode(code="OLD", discount_percent=20, active=False),
PromoCode(code="ZERO", discount_percent=0),
PromoCode(code="TWO", discount_percent=50, max_uses=2),
]
)
for codes, usage, qty in (
(["OLD"], {}, 1),
(["ZERO"], {}, 1),
(["NOPE"], {}, 1),
(["TWO"], {"TWO": 2}, 1),
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
):
totals = basket_totals(event, codes, qty, usage, _wave(event))
assert totals.discounts_applied == []
assert totals.total == totals.subtotal and totals.discount == 0
def test_unlimited_and_partially_used_codes_apply():
event = _event(
codes=[
PromoCode(code="TWO", discount_percent=50, max_uses=2),
PromoCode(code="INF", discount_percent=10),
]
)
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500
assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000
def test_full_discount_prices_to_zero():
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
assert basket_totals(event, ["FREE"], 2, {}, _wave(event)).total == 0
def test_price_comes_from_the_selected_wave_not_the_event():
"""Regression guard for the v1.6.8 merge.
`sync_event_ticket_waves` makes `event.price_per_ticket` a roll-up of the
PRIMARY wave, so pricing off the event charged every buyer the first
wave's price no matter which wave they picked.
"""
event = _event(price=1000.0, codes=[PromoCode(code="HALF", discount_percent=50)])
late = TicketWave(
id="late",
title="Late",
opening_date="2030-01-01",
closing_date="2030-02-01",
currency="sat",
price_per_ticket=2500.0,
amount_tickets=10,
)
assert basket_totals(event, [], 2, {}, _wave(event)).total == 2000
assert basket_totals(event, [], 2, {}, late).total == 5000
assert basket_totals(event, ["HALF"], 2, {}, late).total == 2500

178
tests/test_promo_api.py Normal file
View file

@ -0,0 +1,178 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from .. import views_api
from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest
def _event(codes) -> Event:
return Event(
id="evt",
wallet="w",
name="Test",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01",
event_end_date="2030-01-02",
currency="sat",
price_per_ticket=1000,
amount_tickets=10,
time=datetime.now(timezone.utc),
extra=EventExtra(promo_codes=codes),
status="approved",
)
@pytest.fixture
def event(monkeypatch):
ev = _event(
[
PromoCode(code="HALF", discount_percent=50, max_uses=2),
PromoCode(code="OLD", discount_percent=20, active=False),
]
)
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev))
monkeypatch.setattr(
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1})
)
return ev
@pytest.mark.asyncio
async def test_validate_returns_v2_shaped_totals(event):
totals = await views_api.api_validate_promo_codes(
"evt", PromoValidateRequest(codes=["half"], quantity=1)
)
assert totals.dict() == {
"subtotal": 1000,
"discount": 500,
"total": 500,
"currency": "sat",
"discounts_applied": [
{
"code": "HALF",
"discount_percent": 50,
"discount_fixed": None,
"amount_saved": 500,
}
],
}
@pytest.mark.asyncio
async def test_validate_is_advisory_for_bad_codes(event):
for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)):
totals = await views_api.api_validate_promo_codes(
"evt", PromoValidateRequest(codes=codes, quantity=qty)
)
assert totals.discounts_applied == [] and totals.total == totals.subtotal
@pytest.mark.asyncio
async def test_validate_unknown_event_is_404(monkeypatch):
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None))
with pytest.raises(HTTPException) as exc:
await views_api.api_validate_promo_codes(
"nope", PromoValidateRequest(codes=["X"])
)
assert exc.value.status_code == 404
@pytest.mark.asyncio
@pytest.mark.parametrize(
"code,quantity,detail",
[
("NOPE", 1, "Invalid promo code."),
("old", 1, "Promo code is not active."),
("HALF", 2, "Only 1 use(s) left on this promo code."),
],
)
async def test_purchase_rejects_bad_codes_before_any_invoice(
event, monkeypatch, code, quantity, detail
):
monkeypatch.setattr(
views_api,
"create_payment_request",
AsyncMock(side_effect=AssertionError("must not be called")),
)
data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity)
with pytest.raises(HTTPException) as exc:
await views_api.api_ticket_create(
"evt", data, SimpleNamespace(base_url="http://lnbits.local/")
)
assert exc.value.status_code == 400
assert exc.value.detail == detail
@pytest.mark.asyncio
async def test_purchase_reports_fully_redeemed(event, monkeypatch):
monkeypatch.setattr(
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2})
)
monkeypatch.setattr(
views_api,
"create_payment_request",
AsyncMock(side_effect=AssertionError("must not be called")),
)
with pytest.raises(HTTPException) as exc:
await views_api.api_ticket_create(
"evt",
CreateTicket(user_id="u1", promo_code="HALF"),
SimpleNamespace(base_url="http://lnbits.local/"),
)
assert exc.value.detail == "Promo code has been fully redeemed."
@pytest.fixture
def update_env(monkeypatch):
stored = _event([PromoCode(code="KEEP", discount_percent=10)])
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored))
monkeypatch.setattr(
views_api,
"get_settings",
AsyncMock(return_value=SimpleNamespace(auto_approve=True)),
)
monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock())
return stored
def _update_payload(extra: dict) -> dict:
return {
"wallet": "w",
"name": "Test",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01",
"event_end_date": "2030-01-02",
"amount_tickets": 10,
"price_per_ticket": 1000,
"extra": extra,
}
def _wallet():
return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1"))
@pytest.mark.asyncio
async def test_update_without_promo_key_keeps_stored_codes(update_env):
from ..models import CreateEvent
data = CreateEvent.parse_obj(_update_payload({"email_notifications": True}))
event = await views_api.api_event_update("evt", data, _wallet())
assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"]
assert event.extra.email_notifications is True
@pytest.mark.asyncio
async def test_update_with_empty_promo_list_clears(update_env):
from ..models import CreateEvent
data = CreateEvent.parse_obj(_update_payload({"promo_codes": []}))
event = await views_api.api_event_update("evt", data, _wallet())
assert event.extra.promo_codes == []

View file

@ -0,0 +1,195 @@
"""The NIP-52 tags describe the ACTIVE ticket wave, not the roll-up (#61).
Upstream v1.6.8 moved price, currency and inventory onto time-boxed waves
and made the event-level fields derived: `price_per_ticket` and `currency`
become the PRIMARY (first) wave's, `amount_tickets` the SUM across every
wave. Publishing those would advertise the early-bird price after early
bird closed and count stock in waves that have not opened yet.
"""
from datetime import datetime, timedelta, timezone
from typing import cast
import pytest
from ..models import (
Event,
EventExtra,
TicketWave,
advertised_wave_key,
sync_event_ticket_waves,
)
from ..nostr_publisher import build_nip52_event
PUBKEY = "a" * 64
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,
opens: int,
closes: int,
*,
currency: str = "EUR",
allow_fiat: bool = False,
) -> TicketWave:
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(opens),
closing_date=_day(closes),
currency=currency,
price_per_ticket=price,
amount_tickets=stock,
allow_fiat=allow_fiat,
fiat_currency="GBP",
)
def _event(waves: list[TicketWave], sold: int = 0) -> Event:
event = Event(
id="evt",
wallet="w",
name="Waves",
info="",
closing_date=_day(30),
event_start_date=_day(30),
event_end_date=_day(30),
currency="sat",
price_per_ticket=0,
amount_tickets=0,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
extra=EventExtra(ticket_waves=waves),
)
# Mirrors the CRUD layer, which syncs on every read and write.
return cast(Event, sync_event_ticket_waves(event))
def _tags(event: Event) -> dict[str, str]:
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
CLOSED_EARLY_BIRD = _wave("early", 10.0, 5, -10, -1)
OPEN_REGULAR = _wave("regular", 25.0, 40, 0, 20)
UNOPENED_VIP = _wave("vip", 50.0, 10, 5, 25)
def test_closed_wave_price_is_not_advertised():
"""The defect this fixes: early bird closed yesterday."""
event = _event([CLOSED_EARLY_BIRD, OPEN_REGULAR, UNOPENED_VIP])
# What the event-level roll-up would have published.
assert event.price_per_ticket == 10.0
assert event.amount_tickets == 55
tags = _tags(event)
assert tags["tickets_price"] == "25.0"
assert tags["tickets_available"] == "40"
def test_unopened_wave_stock_is_not_counted():
event = _event([OPEN_REGULAR, UNOPENED_VIP])
assert _tags(event)["tickets_available"] == "40"
def test_cheapest_open_wave_wins_when_several_are_open():
"""A publisher cannot ask which wave the buyer wants, so it advertises
the price a buyer is actually able to obtain."""
cheaper = _wave("cheap", 15.0, 3, 0, 10)
tags = _tags(_event([OPEN_REGULAR, cheaper]))
assert tags["tickets_price"] == "15.0"
assert tags["tickets_available"] == "3"
@pytest.mark.parametrize(
"waves",
[
pytest.param([CLOSED_EARLY_BIRD], id="all-closed"),
pytest.param([UNOPENED_VIP], id="not-yet-open"),
pytest.param([_wave("only", 25.0, 0, 0, 20)], id="sold-out"),
],
)
def test_no_open_wave_publishes_zero_availability(waves):
"""Never omit the tag: omission meant "unlimited" (#34, #62)."""
tags = _tags(_event(waves))
assert tags["tickets_available"] == "0"
def test_currency_and_fiat_follow_the_advertised_wave():
fiat_primary = _wave("a", 10.0, 0, -10, -1, currency="GBP", allow_fiat=True)
sats_open = _wave("b", 2500.0, 9, 0, 20, currency="sat", allow_fiat=False)
tags = _tags(_event([fiat_primary, sats_open]))
assert tags["tickets_currency"] == "sat"
assert "tickets_allow_fiat" not in tags
# Must agree with tickets_allow_fiat — they are the same fact, and a
# client rendering a fiat button here would hit a purchase-time refusal.
assert tags["tickets_payment_methods"] == "lightning"
def test_payment_methods_offer_fiat_when_the_advertised_wave_accepts_it():
tags = _tags(
_event(
[
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
]
)
)
assert tags["tickets_allow_fiat"] == "true"
assert tags["tickets_payment_methods"] == "lightning,fiat"
def test_sold_stays_event_level():
"""`tickets_sold` is the total paid across all waves."""
assert _tags(_event([OPEN_REGULAR], sold=7))["tickets_sold"] == "7"
def test_advertised_wave_key_tracks_the_published_wave():
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD, OPEN_REGULAR])) == "regular"
# 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"

View file

@ -0,0 +1,56 @@
"""`tickets_available` is always published, including zero (#34).
Omitting the tag used to mean "unlimited", which contradicted every other
reader: `api_get_event` and `api_ticket_create` both treat
`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events
advertised "Unlimited tickets" on the card while the detail endpoint and
the purchase both returned 410.
"""
from datetime import datetime, timezone
import pytest
from ..models import Event
from ..nostr_publisher import build_nip52_event
PUBKEY = "a" * 64
def _event(amount_tickets: int, sold: int = 0) -> Event:
return Event(
id="evt",
wallet="w",
name="Availability",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01T18:00",
event_end_date="2030-01-01T22:00",
currency="sat",
price_per_ticket=0,
amount_tickets=amount_tickets,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
)
def _tags(event: Event) -> dict[str, str]:
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
@pytest.mark.parametrize("amount", [0, 1, 50])
def test_tickets_available_is_always_present(amount):
assert _tags(_event(amount))["tickets_available"] == str(amount)
def test_zero_capacity_reads_as_sold_out_not_unlimited():
"""The regression: an absent tag is what clients render as unlimited."""
tags = _tags(_event(0, sold=0))
assert "tickets_available" in tags
assert tags["tickets_available"] == "0"
def test_sold_out_after_selling_through_still_publishes_zero():
assert _tags(_event(0, sold=25))["tickets_available"] == "0"
assert _tags(_event(0, sold=25))["tickets_sold"] == "25"

View file

@ -0,0 +1,105 @@
"""Publish confirmation against the relay's `OK` (aiolabs/events#56).
Queueing is not delivery. nostrclient drops an EVENT outright when no
relay is connected and answers `OK false`; before this, that reply was
discarded and the publish reported success, which once cleared the
`nostr_publish_pending` flag on a republish that never left the
building.
"""
import asyncio
import json
import pytest
from ..nostr import nostr_client as nc
from ..nostr.event import NostrEvent
def _event(event_id: str = "a" * 64) -> NostrEvent:
e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923)
e.id = event_id
return e
async def _publish_and_reply(client, event, reply, delay=0.01):
"""Start a publish, then feed `reply` through the receive path."""
task = asyncio.create_task(client.publish_nostr_event(event))
await asyncio.sleep(delay) # let the future register
if reply is not None:
client.receive_event_queue.put_nowait(reply)
consumer = asyncio.create_task(client.get_event())
await asyncio.sleep(delay)
consumer.cancel()
return await task
@pytest.mark.asyncio
async def test_accepted_publish_returns_true():
client = nc.NostrClient()
event = _event()
ok = json.dumps(["OK", event.id, True, ""])
assert await _publish_and_reply(client, event, ok) is True
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_rejected_publish_returns_false():
"""The shape that bit us: no relay connected, so nostrclient's
router answers `OK false` without the event ever being sent."""
client = nc.NostrClient()
event = _event()
ok = json.dumps(["OK", event.id, False, "error: no relays connected"])
assert await _publish_and_reply(client, event, ok) is False
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_missing_ok_times_out_as_unconfirmed(monkeypatch):
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
client = nc.NostrClient()
assert await _publish_and_reply(client, _event(), None) is False
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch):
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
client = nc.NostrClient()
other = json.dumps(["OK", "c" * 64, True, ""])
assert await _publish_and_reply(client, _event(), other) is False
@pytest.mark.asyncio
async def test_get_event_swallows_ok_and_forwards_everything_else():
client = nc.NostrClient()
client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""]))
forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}])
client.receive_event_queue.put_nowait(forwarded)
assert await client.get_event() == forwarded
@pytest.mark.asyncio
async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch):
"""A dropped socket must not leave the caller waiting the full
timeout for an OK that can no longer arrive."""
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30)
client = nc.NostrClient()
event = _event()
task = asyncio.create_task(client.publish_nostr_event(event))
await asyncio.sleep(0.01)
client.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
consumer = asyncio.create_task(client.get_event())
await asyncio.sleep(0.01)
consumer.cancel()
assert await asyncio.wait_for(task, 1) is False
def test_settle_ok_ignores_non_ok_frames():
client = nc.NostrClient()
assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False
assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False
assert client._settle_ok("not json") is False
assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True

View file

@ -0,0 +1,89 @@
from datetime import datetime, timezone
from lnbits.settings import settings
from ..models import Event, Ticket
from ..services import _ticket_notification_payload, build_ticket_email
def _event(**overrides) -> Event:
data = {
"id": "evt1",
"wallet": "w",
"name": "Test Event",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01T16:00:00+01:00",
"event_end_date": "2030-01-01T20:00:00+01:00",
"location": "The Chateau",
"amount_tickets": 10,
"price_per_ticket": 5,
"time": datetime.now(timezone.utc),
}
data.update(overrides)
return Event(**data)
def _ticket(**overrides) -> Ticket:
now = datetime.now(timezone.utc)
data = {
"id": "tkt1",
"wallet": "w",
"event": "evt1",
"name": "Ada",
"email": "ada@example.com",
"registered": False,
"paid": True,
"time": now,
"reg_timestamp": now,
}
data.update(overrides)
return Ticket(**data)
def test_email_carries_date_message_id_and_display_name(monkeypatch):
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
msg = build_ticket_email(
"tickets@example.org", ["ada@example.com"], "Subj", "text", "<p>html</p>"
)
assert msg["Date"]
assert msg["Message-ID"].endswith("@example.org>")
assert msg["From"] == "Oyez! <tickets@example.org>"
parts = [p.get_content_type() for p in msg.get_payload()]
assert parts == ["text/plain", "text/html"]
def test_email_attaches_the_ticket_card(monkeypatch):
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
msg = build_ticket_email(
"tickets@example.org",
["ada@example.com"],
"Subj",
"text",
"<p>html</p>",
attachments=[("ticket-test-8auNuuaB.png", png)],
)
assert msg.get_content_type() == "multipart/mixed"
body, attachment = msg.get_payload()
assert body.get_content_type() == "multipart/alternative"
assert attachment.get_content_type() == "image/png"
assert attachment.get_filename() == "ticket-test-8auNuuaB.png"
assert attachment.get_payload(decode=True) == png
def test_payload_includes_event_details_and_qr(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
subject, text, html = _ticket_notification_payload(_ticket(), _event())
assert "Test Event" in subject
for needle in (
"Event: Test Event",
"Where: The Chateau",
"Name on ticket: Ada",
"Ticket ID: tkt1",
"at the door",
):
assert needle in text
assert "/events/api/v1/ticket-card/tkt1" in text
assert "<img" not in html and "Ticket ID: tkt1" in html
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html

View file

@ -5,6 +5,10 @@ from ..models import (
CreateEvent, CreateEvent,
CreateTicket, CreateTicket,
EventExtra, EventExtra,
PromoCode,
PublicEvent,
PublicEventExtra,
TicketWave,
effective_payment_methods, effective_payment_methods,
) )
@ -108,3 +112,108 @@ def test_payment_methods_are_normalised_and_deduplicated():
def test_unknown_payment_method_is_rejected(): def test_unknown_payment_method_is_rejected():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
EventExtra(payment_methods=["cash"]) 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)) == []

View file

@ -2,7 +2,7 @@ from io import BytesIO
from PIL import Image from PIL import Image
from ..views_api import make_qr_png from ..qr import make_qr_png
def test_make_qr_png_renders_requested_size(): def test_make_qr_png_renders_requested_size():
@ -20,3 +20,45 @@ def test_make_qr_png_pastes_a_centred_logo():
out = BytesIO() out = BytesIO()
img.save(out, format="PNG") img.save(out, format="PNG")
assert out.getvalue().startswith(b"\x89PNG") assert out.getvalue().startswith(b"\x89PNG")
def test_render_ticket_card_is_self_describing():
from datetime import datetime, timezone
from ..models import Event, Ticket
from ..qr import format_event_when, render_ticket_card, ticket_card_filename
now = datetime.now(timezone.utc)
event = Event(
id="evt1",
wallet="w",
name="Château du Faune | Uru Ecstatic Dance",
info="",
closing_date="2027-02-19",
event_start_date="2027-02-19T16:00:00+01:00",
event_end_date="2027-02-19T20:00:00+01:00",
location="The Chateau",
amount_tickets=10,
price_per_ticket=15,
time=now,
)
ticket = Ticket(
id="8auNuuaBv7TFGj7BYoVnTN",
wallet="w",
event="evt1",
name="Guest Test",
email="g@example.com",
registered=False,
paid=True,
time=now,
reg_timestamp=now,
)
assert format_event_when(event) == "Fri 19 Feb 2027, 16:00 - 20:00"
assert (
ticket_card_filename(ticket, event)
== "ticket-ch-teau-du-faune-uru-ecstatic-dance-8auNuuaB.png"
)
card = render_ticket_card(ticket, event, site_title="Oyez!")
assert card.width == 800 and card.height > 800
# QR area is centred; its finder pattern is black
assert card.getpixel((400, card.height // 2)) in ((0, 0, 0), (255, 255, 255))

View file

@ -0,0 +1,118 @@
"""Capacity is required per ticket wave (#34, #62).
"Capacity is always required, there is no unlimited" was settled when
capacity was one number on the event. Since v1.6.8 it is per-wave and
`event.amount_tickets` is a derived roll-up, so the rule has to bite where
the organiser sets it. A zero-capacity wave can never be active
(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of
the trap #62 removed: an event that looks on sale but refuses every
purchase.
"""
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
import pytest
from fastapi import HTTPException
from ..models import CreateEvent, Event, EventExtra, TicketWave
from ..views_api import _validate_wave_capacity
TODAY = datetime.now(timezone.utc).date()
def _day(offset: int) -> str:
return (TODAY + timedelta(days=offset)).isoformat()
def _wave(wave_id: str, stock: int) -> TicketWave:
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(0),
closing_date=_day(20),
currency="sat",
price_per_ticket=10,
amount_tickets=stock,
)
def _create(waves=None, amount_tickets=10) -> CreateEvent:
return CreateEvent(
wallet="w",
name="Fete",
info="",
event_start_date=_day(30),
currency="sat",
price_per_ticket=10,
amount_tickets=amount_tickets,
extra=EventExtra(ticket_waves=waves or []),
)
def _stored(waves) -> Event:
return Event(
id="evt",
wallet="w",
name="Fete",
info="",
closing_date=_day(30),
event_start_date=_day(30),
currency="sat",
price_per_ticket=10,
amount_tickets=sum(w.amount_tickets for w in waves),
time=datetime.now(timezone.utc),
extra=EventExtra(ticket_waves=waves),
)
def _detail(exc_info) -> str:
assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST
return exc_info.value.detail
def test_wave_with_capacity_is_accepted():
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)]))
def test_zero_capacity_wave_is_rejected_on_create():
with pytest.raises(HTTPException) as exc_info:
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)]))
assert "late" in _detail(exc_info)
def test_event_submitted_without_waves_is_checked_through_its_primary_wave():
"""No waves means the event gets a synthesized primary wave seeded from
`amount_tickets`, so the rule must see that rather than an empty list."""
_validate_wave_capacity(_create(waves=None, amount_tickets=10))
with pytest.raises(HTTPException) as exc_info:
_validate_wave_capacity(_create(waves=None, amount_tickets=0))
assert "Primary wave" in _detail(exc_info)
def test_selling_a_wave_out_does_not_block_later_edits():
"""Zero is where a wave legitimately ends up. Rejecting it on edit would
make a sold-out event uneditable."""
sold_out = _wave("early", 0)
existing = _stored([sold_out, _wave("late", 10)])
_validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing)
def test_new_wave_added_by_an_edit_still_needs_capacity():
existing = _stored([_wave("early", 5)])
with pytest.raises(HTTPException) as exc_info:
_validate_wave_capacity(
_create([_wave("early", 5), _wave("brand-new", 0)]), existing
)
assert "brand-new" in _detail(exc_info)
def test_legacy_zero_capacity_event_stays_editable():
"""An event stored before the rule existed keeps its primary wave id, so
an edit is not blocked — otherwise the only way to fix it would be
barred."""
existing = _stored([_wave("primary", 0)])
_validate_wave_capacity(_create([_wave("primary", 0)]), existing)

View file

@ -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"

View file

@ -0,0 +1,159 @@
"""Wave boundaries trigger a republish (#61).
Every republish this extension performs is sale-driven. A wave boundary is
a *date* boundary, so when early bird closes at midnight nothing fires and
the relay keeps serving the closed wave's price until the next ticket
happens to sell. `flag_wave_transitions` closes that gap by comparing the
wave a row would advertise now against the one its last successful publish
did, handing the work to the existing reconciliation sweep.
"""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from .. import crud, nostr_hooks
from ..models import Event, EventExtra, 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, opens: int, closes: int):
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(opens),
closing_date=_day(closes),
currency="EUR",
price_per_ticket=price,
amount_tickets=stock,
)
def _event(waves, published_wave_id, pending=False) -> Event:
return Event(
id="evt",
wallet="w",
name="Waves",
info="",
closing_date=_day(30),
event_start_date=_day(30),
event_end_date=_day(30),
currency="sat",
price_per_ticket=0,
amount_tickets=0,
time=datetime.now(timezone.utc),
status="approved",
nostr_publish_pending=pending,
nostr_published_wave_id=published_wave_id,
extra=EventExtra(ticket_waves=waves),
)
@pytest.fixture
def swept(monkeypatch):
"""Capture rows the detector writes back."""
written: list[Event] = []
async def _update(event):
written.append(event)
return event
monkeypatch.setattr(crud, "update_event", _update)
return written
def _rows(monkeypatch, events):
monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events))
CLOSED = _wave("early", 10.0, 5, -10, -1)
OPEN = _wave("regular", 25.0, 40, 0, 20)
@pytest.mark.asyncio
async def test_moved_wave_is_flagged(monkeypatch, swept):
"""Early bird closed; the relay still advertises it."""
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")])
assert await crud.flag_wave_transitions() == 1
assert [e.nostr_publish_pending for e in swept] == [True]
@pytest.mark.asyncio
async def test_unchanged_wave_is_left_alone(monkeypatch, swept):
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")])
assert await crud.flag_wave_transitions() == 0
assert swept == []
@pytest.mark.asyncio
async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept):
"""No wave open is itself an advertisable state, and a different one."""
sold_out = _wave("regular", 25.0, 0, 0, 20)
_rows(monkeypatch, [_event([sold_out], published_wave_id="regular")])
assert await crud.flag_wave_transitions() == 1
@pytest.mark.asyncio
async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept):
""" "" means "published while nothing was on sale" — not drift."""
_rows(monkeypatch, [_event([CLOSED], published_wave_id="")])
assert await crud.flag_wave_transitions() == 0
assert swept == []
@pytest.mark.asyncio
async def test_never_published_rows_are_skipped(monkeypatch, swept):
"""NULL is no evidence of drift. Flagging these would republish the
whole table on the first boot after the upgrade."""
_rows(monkeypatch, []) # the SQL filters them out
assert await crud.flag_wave_transitions() == 0
@pytest.mark.asyncio
async def test_publish_records_the_advertised_wave(monkeypatch):
"""The sweep can only detect drift if a success writes the wave down."""
event = _event([CLOSED, OPEN], published_wave_id="early")
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet",
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
)
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
assert event.nostr_published_wave_id == "regular"
@pytest.mark.asyncio
async def test_delete_does_not_record_a_wave(monkeypatch):
"""A takedown advertises nothing, so it must not claim a wave."""
event = _event([CLOSED, OPEN], published_wave_id="early")
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet",
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
)
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
assert event.nostr_published_wave_id == "early"

View file

@ -6,8 +6,6 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
import httpx
import pyqrcode # type: ignore[import-untyped]
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@ -19,17 +17,20 @@ from fastapi import (
) )
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.crud.assets import get_public_asset
from lnbits.core.crud.wallets import get_wallet from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, User, WalletTypeInfo from lnbits.core.models import Account, User, WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request from lnbits.core.services import create_payment_request
from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_admin, check_admin,
check_user_exists, check_user_exists,
parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
@ -37,8 +38,7 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
from lnbits.utils.nostr import normalize_public_key from lnbits.utils.nostr import normalize_public_key
from loguru import logger from PIL import Image
from PIL import Image, ImageDraw
from .crud import ( from .crud import (
create_event, create_event,
@ -58,35 +58,63 @@ from .crud import (
get_tickets_by_event, get_tickets_by_event,
get_tickets_by_payment_hash, get_tickets_by_payment_hash,
get_tickets_by_user_id, get_tickets_by_user_id,
get_tickets_paginated,
purge_unpaid_tickets, purge_unpaid_tickets,
update_event, update_event,
update_settings, update_settings,
update_ticket, update_ticket,
) )
from .models import ( from .models import (
BasketTotals,
CreateEvent, CreateEvent,
CreateTicket, CreateTicket,
Event, Event,
EventsSettings, EventsSettings,
PromoValidateRequest,
PublicEvent, PublicEvent,
PublicTicket, PublicTicket,
Ticket, Ticket,
TicketFilters,
TicketPaymentRequest, TicketPaymentRequest,
TicketResendResult, TicketResendResult,
TicketWave,
effective_payment_methods, effective_payment_methods,
ensure_ticket_waves,
get_active_ticket_waves,
) )
from .nostr_hooks import publish_or_delete_nostr_event from .nostr_hooks import publish_or_delete_nostr_event
from .promo import (
basket_totals,
find_promo,
normalize_code,
remaining_uses,
round_amount,
)
from .qr import (
image_png_bytes,
load_qr_logo,
make_qr_png,
render_ticket_card,
ticket_card_filename,
)
from .services import ( from .services import (
event_promo_usage,
hydrate_promo_usage,
refund_tickets, refund_tickets,
resend_ticket_email_notification, resend_ticket_email_notification,
send_ticket_notification_in_background, send_ticket_notification_in_background,
set_ticket_paid, set_ticket_paid,
) )
from .tasks import deregister_payment_listener, register_payment_listener from .tasks import (
deregister_payment_listener,
register_payment_listener,
)
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets") tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1") qr_api_router = APIRouter(prefix="/api/v1")
promo_api_router = APIRouter(prefix="/api/v1/promo")
tickets_filters = parse_filters(TicketFilters)
def _is_fiat_currency(currency: str | None) -> bool: def _is_fiat_currency(currency: str | None) -> bool:
@ -95,8 +123,6 @@ def _is_fiat_currency(currency: str | None) -> bool:
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared # Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
# before any "/{event_id}" route or FastAPI matches them as a path parameter. # before any "/{event_id}" route or FastAPI matches them as a path parameter.
@events_api_router.get("") @events_api_router.get("")
async def api_events( async def api_events(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
@ -106,12 +132,17 @@ async def api_events(
if all_wallets: if all_wallets:
user = await get_user(wallet.wallet.user) user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
return await get_events(wallet_ids) events = await get_events(wallet_ids)
for event in events:
await hydrate_promo_usage(event)
return events
@events_api_router.get("/public") @events_api_router.get("/public", response_model=list[PublicEvent])
async def api_events_public() -> list[Event]: async def api_events_public() -> list[Event]:
"""Approved, non-canceled events for an anonymous public listing.""" """Approved, non-canceled events for an anonymous public listing.
Projected through `PublicEvent`: no wallet id, no promo codes."""
return await get_public_events() return await get_public_events()
@ -129,6 +160,7 @@ async def api_events_all(
events = await get_all_events() events = await get_all_events()
enriched: list[dict] = [] enriched: list[dict] = []
for event in events: for event in events:
await hydrate_promo_usage(event)
wallet = await get_wallet(event.wallet) wallet = await get_wallet(event.wallet)
row = event.dict() row = event.dict()
row["wallet_user_id"] = wallet.user if wallet else None row["wallet_user_id"] = wallet.user if wallet else None
@ -253,33 +285,80 @@ async def api_get_event(event_id: str) -> Event:
closing_date = event.closing_date or event.event_end_date or event.event_start_date closing_date = event.closing_date or event.event_end_date or event.event_start_date
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date # Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
# may carry a time component since v1.3.0-aio.3 / our start-end-time # may carry a time component since v1.3.0-aio.3 / our start-end-time
# feature). # feature). Upstream v1.6.8 parses closing_date with a bare
# strptime("%Y-%m-%d") here; that raises ValueError on any event of ours
# whose closing date carries a time, which is most of them.
try: try:
closing_dt = datetime.fromisoformat(closing_date) closing_dt = datetime.fromisoformat(closing_date)
except ValueError: except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d") closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None: if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc) closing_dt = closing_dt.replace(tzinfo=timezone.utc)
is_window_open = datetime.now(timezone.utc) < closing_dt
now = datetime.now(timezone.utc)
today = now.date()
active_waves = get_active_ticket_waves(event, today)
# `is_sales_closed` is upstream's name for `not is_window_open`; the
# comparison stays ours (instant-precise) rather than upstream's
# whole-day `today > closing_date.date()`. Upstream's form keeps sales
# open for the whole of the closing day, which for our datetime-bearing
# closing dates would extend every existing event's sales window.
is_sales_closed = now >= closing_dt
is_min_tickets_met = ( is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True event.sold >= event.extra.min_tickets if event.extra.conditional else True
) )
if event.amount_tickets < 1: if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if event.extra.conditional and not is_min_tickets_met and not is_window_open: if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
event.canceled = True event.canceled = True
await update_event(event) await update_event(event)
await refund_tickets(event_id) await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not is_window_open: if not active_waves:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." status_code=HTTPStatus.GONE,
detail=(
"Ticket closing date has passed."
if is_sales_closed
else "No ticket wave is currently open."
),
) )
return event return event
def _validate_wave_capacity(data: CreateEvent, existing: Event | None = None) -> None:
"""Every ticket wave must state a real capacity.
"Capacity is always required, there is no unlimited" (#34, #62) was
settled when capacity was a single number on the event. Since v1.6.8 it
is per-wave and `event.amount_tickets` is a derived roll-up, so the rule
has to be enforced where the organiser actually sets it — otherwise it
only survives as a `min="1"` on one HTML input, which no API client is
bound by.
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.
Selling out is the one legitimate route to zero, so an edit only checks
waves that are NEW to the event; waves already stored keep whatever
sales decremented them to. `ensure_ticket_waves` is used rather than the
raw list so an event submitted with no waves is checked through the
primary wave it will be given.
"""
known = {wave.id for wave in (existing.extra.ticket_waves if existing else [])}
for wave in ensure_ticket_waves(data):
if wave.id in known or wave.amount_tickets >= 1:
continue
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Ticket wave '{wave.title}' needs a capacity of at least 1.",
)
@events_api_router.post("") @events_api_router.post("")
async def api_event_create( async def api_event_create(
data: CreateEvent, data: CreateEvent,
@ -293,6 +372,8 @@ async def api_event_create(
if not data.wallet: if not data.wallet:
data.wallet = wallet.wallet.id data.wallet = wallet.wallet.id
_validate_wave_capacity(data)
from lnbits.settings import settings from lnbits.settings import settings
ext_settings = await get_settings() ext_settings = await get_settings()
@ -335,6 +416,22 @@ async def api_event_update(
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") 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 from lnbits.settings import settings
ext_settings = await get_settings() ext_settings = await get_settings()
@ -351,6 +448,13 @@ async def api_event_update(
if not data.closing_date: if not data.closing_date:
data.closing_date = data.event_end_date data.closing_date = data.event_end_date
# Promo codes are organizer-only and absent from public responses, so a
# client that round-trips a public record (or simply doesn't manage
# codes) would otherwise wipe them on every edit. Carry the stored list
# over unless the request names the key; an explicit `[]` still clears.
if "promo_codes" not in data.extra.__fields_set__:
data.extra.promo_codes = event.extra.promo_codes
# Explicit field list — never copy `status` from the request body. # Explicit field list — never copy `status` from the request body.
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an # Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
# owner editing a fiat-enabled event keeps the fiat config. # owner editing a fiat-enabled event keeps the fiat config.
@ -509,6 +613,31 @@ async def api_tickets_by_user(
return await get_tickets_by_user_id(user_id) return await get_tickets_by_user_id(user_id)
@tickets_api_router.get(
"/paginated",
summary="Get paginated list of tickets",
openapi_extra=generate_filter_params_openapi(TicketFilters),
response_model=Page[Ticket],
)
async def api_tickets_paginated(
all_wallets: bool = Query(False),
filters: Filters = Depends(tickets_filters),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Page[Ticket]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
if not filters.sortby:
filters.sortby = "time"
if not filters.direction:
filters.direction = "desc"
return await get_tickets_paginated(wallet_ids, filters)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket: async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
@ -570,89 +699,35 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
return data.frontend_url.rstrip("/") return data.frontend_url.rstrip("/")
_qr_logo_cache: dict[str, Image.Image | None] = {} def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave:
"""The wave a purchase or a price preview is priced against.
Shared by the purchase and promo-validate endpoints so the two cannot
async def _load_qr_logo() -> Image.Image | None: drift: whatever wave the preview quoted is the wave the invoice charges.
"""LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached). Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a
single open wave is implied, and an ambiguous choice is an error rather
Local `/static/...` values resolve inside the LNbits package; absolute than a silent pick.
URLs are fetched once. Any failure just yields a plain QR.
""" """
source = (settings.lnbits_qr_logo or "").strip() active_waves = get_active_ticket_waves(event)
if not source: if not active_waves:
return None raise HTTPException(
if source in _qr_logo_cache: status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
return _qr_logo_cache[source]
logo: Image.Image | None = None
try:
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(source)
resp.raise_for_status()
logo = Image.open(BytesIO(resp.content)).convert("RGBA")
else:
local = Path(settings.lnbits_path) / source.lstrip("/")
if local.is_file():
logo = Image.open(local).convert("RGBA")
except Exception as exc:
logger.warning(f"QR logo '{source}' unavailable: {exc}")
logo = None
_qr_logo_cache[source] = logo
return logo
def make_qr_png(
data: str,
size: int = 235,
border: int = 4,
logo: Image.Image | None = None,
) -> Image.Image:
"""Render `data` as a QR image (upstream v1.6.8 shape). With `logo`, the
code is built at error-correction level H and the logo is pasted in the
centre on a white pad at ≤ 20 % of the width — the same look LNbits'
client-side `lnbits-qrcode` component produces."""
qr = pyqrcode.create(data, error="H" if logo is not None else "M")
matrix = qr.code
modules = len(matrix)
total_modules = modules + border * 2
box_size = max(1, size // total_modules)
img_size = total_modules * box_size
img = Image.new("RGBA", (img_size, img_size), "white")
draw = ImageDraw.Draw(img)
for y, row in enumerate(matrix):
for x, cell in enumerate(row):
if cell:
x0 = (x + border) * box_size
y0 = (y + border) * box_size
draw.rectangle(
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
fill="black",
) )
if ticket_wave_id:
if img_size != size: wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None)
img = img.resize((size, size), Image.Resampling.NEAREST) if not wave:
raise HTTPException(
if logo is not None: status_code=HTTPStatus.BAD_REQUEST,
logo_size = max(8, int(size * 0.2)) detail="Invalid ticket wave selected.",
pad = max(2, logo_size // 8)
scaled = logo.copy()
scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
plate = Image.new(
"RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
) )
plate.paste(scaled, (pad, pad), scaled) return wave
img.paste( if len(active_waves) == 1:
plate, return active_waves[0]
((size - plate.width) // 2, (size - plate.height) // 2), raise HTTPException(
plate, status_code=HTTPStatus.BAD_REQUEST,
detail="Please select a ticket wave.",
) )
return img
async def _issue_free_tickets( async def _issue_free_tickets(
*, *,
@ -664,6 +739,7 @@ async def _issue_free_tickets(
promo_code: str | None, promo_code: str | None,
nostr_identifier: str | None, nostr_identifier: str | None,
frontend_root: str, frontend_root: str,
selected_wave: TicketWave,
) -> TicketPaymentRequest: ) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice. """Issue `quantity` free tickets without minting an invoice.
@ -692,6 +768,11 @@ async def _issue_free_tickets(
ticket_id=row_id, ticket_id=row_id,
extra={ extra={
"applied_promo_code": promo_code, "applied_promo_code": promo_code,
# Free tickets consume wave stock exactly like paid ones —
# `set_ticket_paid` runs on this path too — so they must name
# their wave or the decrement lands on the primary wave.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"nostr_identifier": nostr_identifier, "nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root, "ticket_base_url": frontend_root,
"sats_paid": 0, "sats_paid": 0,
@ -727,22 +808,23 @@ async def api_ticket_create(
if event.canceled: if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
quantity = data.quantity quantity = data.quantity
if event.amount_tickets > 0: # `amount_tickets` IS the remaining count — `set_ticket_paid` decrements
if event.sold >= event.amount_tickets: # it on every sale, and upstream reads it the same way everywhere. Do
raise HTTPException( # not subtract `sold` from it: `sold` counts the same tickets the
status_code=HTTPStatus.GONE, detail="Event is sold out." # decrement already removed, so doing both takes each sale off twice
) # (aiolabs/events#34).
remaining = event.amount_tickets - event.sold if event.amount_tickets < 1:
if quantity > remaining: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if quantity > event.amount_tickets:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=f"Only {remaining} ticket(s) remaining for this event.", detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.",
) )
name = data.name name = data.name
email = data.email email = data.email
user_id = data.user_id user_id = data.user_id
promo_code = data.promo_code.upper() if data.promo_code else None promo_code = normalize_code(data.promo_code)
refund_address = data.refund_address refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower() payment_method = (data.payment_method or "lightning").lower()
@ -751,6 +833,11 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.", detail="Unsupported payment method.",
) )
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
# true for upstream, false here: `_send_nostr_ticket_notification` sends
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
# conflict marker, so it would have silently dropped npub checkout.
if nostr_identifier and "@" not in nostr_identifier: if nostr_identifier and "@" not in nostr_identifier:
try: try:
nostr_identifier = normalize_public_key(nostr_identifier) nostr_identifier = normalize_public_key(nostr_identifier)
@ -759,22 +846,50 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.", detail="Invalid Nostr identifier.",
) from exc ) from exc
unit_price = event.price_per_ticket
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request) frontend_root = _resolve_frontend_root(data, request)
# One invoice, N tickets; the promo (if any) prices the whole quantity
# through the same `basket_totals` the validate endpoint uses, so the
# preview a buyer saw is what gets charged. Unlike validate, a bad code
# is a hard error here — silently charging full price would be worse.
if promo_code: if promo_code:
# check if promo_code exists in event.extra.promo_codes promo = find_promo(event, promo_code)
if promo_code not in [pc.code for pc in event.extra.promo_codes]: if not promo:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
) )
# get the promocode if not promo.active:
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Promo code is not active.",
)
usage = await event_promo_usage(event.id)
uses_left = remaining_uses(promo, usage.get(promo.code, 0))
if uses_left is not None and uses_left < quantity:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
"Promo code has been fully redeemed."
if uses_left == 0
else f"Only {uses_left} use(s) left on this promo code."
),
)
extra["promo_code"] = promo.code extra["promo_code"] = promo.code
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100) # Priced off `selected_wave`, not `event`: since v1.6.8 the price and
# Scale by quantity AFTER the promo applies. One invoice, N tickets. # currency live on the wave, and the event-level fields are a roll-up
price = unit_price * quantity # of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the
# event charges every buyer the first wave's price whichever wave they
# actually picked. Upstream prices one ticket; `basket_totals` keeps
# our quantity + promo arithmetic, so the "Apply" preview and the
# invoice still agree.
price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total
else:
price = round_amount(
selected_wave.price_per_ticket * quantity, selected_wave.currency
)
# Free tickets (final charge 0 — a free event or a 100%-off promo). # Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning # Short-circuit before any invoice / fiat-provider logic: no Lightning
@ -790,6 +905,16 @@ async def api_ticket_create(
promo_code=promo_code, promo_code=promo_code,
nostr_identifier=nostr_identifier, nostr_identifier=nostr_identifier,
frontend_root=frontend_root, frontend_root=frontend_root,
selected_wave=selected_wave,
)
# Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods`
# below reads `event.allow_fiat`, which is only the PRIMARY wave's, so
# this check is what actually protects a non-fiat wave.
if payment_method == "fiat" and not selected_wave.allow_fiat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Fiat payments are not enabled for this ticket wave.",
) )
# Organizer-controlled rails (extra.payment_methods; legacy events fall # Organizer-controlled rails (extra.payment_methods; legacy events fall
@ -801,23 +926,24 @@ async def api_ticket_create(
detail="Payment method not enabled for this event.", detail="Payment method not enabled for this event.",
) )
if _is_fiat_currency(event.currency): if _is_fiat_currency(selected_wave.currency):
extra["fiat"] = True extra["fiat"] = True
extra["currency"] = event.currency extra["currency"] = selected_wave.currency
extra["fiatAmount"] = price extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency) extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
if payment_method != "fiat": if payment_method != "fiat":
price = await fiat_amount_as_satoshis(price, event.currency) price = await fiat_amount_as_satoshis(price, selected_wave.currency)
invoice_unit = event.currency invoice_unit = selected_wave.currency
fiat_amount = price fiat_amount = price
fiat_provider = None fiat_provider = None
if payment_method == "fiat": if payment_method == "fiat":
if _is_fiat_currency(event.currency): if _is_fiat_currency(selected_wave.currency):
invoice_unit = event.currency invoice_unit = selected_wave.currency
else: else:
invoice_unit = event.fiat_currency invoice_unit = selected_wave.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True extra["fiat"] = True
extra["currency"] = invoice_unit extra["currency"] = invoice_unit
@ -866,6 +992,7 @@ async def api_ticket_create(
"event_id": event.id, "event_id": event.id,
"quantity": str(quantity), "quantity": str(quantity),
"ticket_ids": ",".join(ticket_ids), "ticket_ids": ",".join(ticket_ids),
**({"promo_code": promo_code} if promo_code else {}),
}, },
} }
@ -892,6 +1019,12 @@ async def api_ticket_create(
ticket_id=row_id, ticket_id=row_id,
extra={ extra={
"applied_promo_code": promo_code, "applied_promo_code": promo_code,
# Which wave this ticket came from. `set_ticket_paid` debits
# the wave named here and falls back to waves[0] when it is
# absent, so omitting it would take every sale off the
# primary wave no matter what the buyer bought.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address, "refund_address": refund_address,
"nostr_identifier": nostr_identifier, "nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root, "ticket_base_url": frontend_root,
@ -995,7 +1128,9 @@ async def api_ticket_delete(
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
async def api_ticket_resend_email( async def api_ticket_resend_email(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) ticket_id: str,
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> TicketResendResult: ) -> TicketResendResult:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
@ -1013,16 +1148,13 @@ async def api_ticket_resend_email(
) )
try: try:
return await resend_ticket_email_notification(ticket) return await resend_ticket_email_notification(
ticket, str(request.base_url).rstrip("/")
)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc ) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to resend ticket email.",
) from exc
@tickets_api_router.put("/register/{ticket_id}") @tickets_api_router.put("/register/{ticket_id}")
@ -1124,6 +1256,7 @@ async def api_event_ticket_stats(
"registered_at": ( "registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None t.reg_timestamp.isoformat() if t.reg_timestamp else None
), ),
"applied_promo_code": t.extra.applied_promo_code,
} }
for t in paid_tickets for t in paid_tickets
], ],
@ -1132,27 +1265,122 @@ async def api_event_ticket_stats(
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) @qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_qr(ticket_id: str): async def api_ticket_qr(ticket_id: str):
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the """PNG of the ticket's scan payload (`ticket://<id>`).
instance QR logo. Anonymous by design — it is what the ticket email
embeds — and the id is the same bearer token the ticket page exposes. Two shapes behind one route. Before the v1.6.8 merge this endpoint
Port of upstream v1.6.8 without ticket-image compositing.""" existed on both sides — ours was "upstream's, without ticket-image
compositing", theirs added the compositing but dropped the logo. They
are the same endpoint, so they are merged rather than registered twice
(FastAPI serves whichever route is declared first, so the second copy
would have been silently unreachable):
- wave opted into `use_ticket_image`: upstream's composite — the QR
pasted onto the organiser's uploaded template, or the bundled
default. QR size and paste coordinates are upstream's and are tied
to each other; no logo, because the template carries the branding.
- otherwise: our standalone QR at 300px with the instance logo.
Anonymous by design — it is what the ticket email links to — and the id
is the same bearer token the ticket page exposes.
"""
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
) )
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
logo = await _load_qr_logo() headers = {
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
output = BytesIO()
image.save(output, format="PNG")
output.seek(0)
return StreamingResponse(
output,
media_type="image/png",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate", "Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache", "Pragma": "no-cache",
"Expires": "0", "Expires": "0",
}
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
logo = await load_qr_logo()
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
return StreamingResponse(
BytesIO(image_png_bytes(image)),
media_type="image/png",
headers=headers,
)
background_bytes = None
if wave.ticket_image_id:
asset = await get_public_asset(wave.ticket_image_id)
if asset:
background_bytes = asset.data
if background_bytes:
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
else:
default_template = (
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
)
ticket_image = Image.open(default_template).convert("RGBA")
ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505))
output = BytesIO()
ticket_image.save(output, format="PNG")
output.seek(0)
return StreamingResponse(output, media_type="image/png", headers=headers)
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_card(ticket_id: str):
"""Self-describing ticket PNG (event, when, where, QR, name, id) — the
same image the ticket email attaches. Anonymous like the QR endpoint."""
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
logo = await load_qr_logo()
card = render_ticket_card(
ticket, event, logo=logo, site_title=settings.lnbits_site_title
)
filename = ticket_card_filename(ticket, event)
return StreamingResponse(
BytesIO(image_png_bytes(card)),
media_type="image/png",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
"Cache-Control": "no-cache, no-store, must-revalidate",
}, },
) )
@promo_api_router.post("/validate/{event_id}")
async def api_validate_promo_codes(
event_id: str, data: PromoValidateRequest
) -> BasketTotals:
"""Price a purchase with the given codes without committing to it —
what the buyer sees before paying. Anonymous and advisory: a code that
is unknown / inactive / exhausted is simply absent from
`discounts_applied`; the purchase endpoint is where hard errors live.
Same URL as upstream v2 (`quantity` instead of v2's `items`)."""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
# Same resolver the purchase endpoint uses, so the quote and the charge
# are priced against the same wave.
wave = _resolve_ticket_wave(event, data.ticket_wave_id)
return basket_totals(event, data.codes, data.quantity, usage, wave)