Commit graph

144 commits

Author SHA1 Message Date
69c9a4f757 chore(release): v1.6.8-aio.3
Some checks failed
lint.yml / chore(release): v1.6.8-aio.3 (push) Failing after 0s
v1.6.8-aio.3
- #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
v1.6.8-aio.2
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
v1.6.8-aio.1
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
v1.6.1-aio.18
- #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
v1.6.1-aio.17
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
v1.6.1-aio.16
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
v1.6.1-aio.15
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
v1.6.1-aio.14
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
v1.6.1-aio.13
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
v1.6.1-aio.10
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
v1.6.1-aio.9
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
8b423f185a Merge pull request 'feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8)' (#36) from feat/guest-checkout-email into main
Some checks failed
lint.yml / Merge pull request 'feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8)' (#36) from feat/guest-checkout-email into main (push) Failing after 0s
v1.6.1-aio.8
Reviewed-on: #36
2026-09-07 09:27:50 +00:00
07124b36ae chore: ignore the data/ dir pytest creates
Some checks failed
lint.yml / chore: ignore the data/ dir pytest creates (pull_request) Failing after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:57:25 +02:00
bebf7becc8 chore: run tests against the aio lnbits fork (PYTHONPATH), not PyPI lnbits
Some checks failed
lint.yml / chore: run tests against the aio lnbits fork (PYTHONPATH), not PyPI lnbits (pull_request) Failing after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:56:55 +02:00
a67c6faff3 docs: guest checkout contract, upstream-candidates log; bump 1.6.1-aio.8
Some checks failed
lint.yml / docs: guest checkout contract, upstream-candidates log; bump 1.6.1-aio.8 (pull_request) Failing after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:53:05 +02:00
c2d9a96239 feat: per-event payment methods (extra.payment_methods)
Organizers pick which rails an event accepts — Lightning, card (fiat) or
both — instead of a bare "allow fiat" toggle. `extra.payment_methods`
uses the field name upstream v2 (lnbits/events#64) introduces so the
eventual rebase merges cleanly; an empty list keeps the legacy rule
(Lightning always, fiat when allow_fiat), and allow_fiat stays the
fiat-currency carrier, kept in lockstep on save. The effective list is
published as the NIP-52 tag `tickets_payment_methods` so clients render
exactly the buttons the purchase endpoint will accept, and the buyer page
defaults to the first accepted rail (a card-only event never submits
"lightning").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:53:05 +02:00
92642a1f24 feat: multipart ticket email with embedded QR, structured resend result
Port of upstream v1.6.8's delivery layer, wave-free: `_deliver_ticket_
notifications` sends text + HTML (the HTML embeds the ticket QR PNG from
this extension on the LNbits host — built from lnbits_baseurl on purpose,
since ticket_base_url may point at a separate web app) and returns a
`TicketResendResult` with per-channel attempted/sent/error. The SMTP
session runs via asyncio.to_thread so a slow relay cannot stall the event
loop while a batch of tickets settles. Resend keeps bypassing the
per-event email opt-in (organizer asked explicitly) and is email-only.
Our nsec-DM Nostr path is kept (upstream went NIP-05-only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:53:05 +02:00
f77ad28bdd feat: return buyers to the calling app after Stripe, branded QR endpoint
`_resolve_frontend_root` honours `CreateTicket.frontend_url` when its
origin is one of LNBITS_CORS_ALLOWED_ORIGINS, the LNbits base URL or
LNBITS_CUSTOM_FRONTEND_URL (400 otherwise — a silent fallback would send
the buyer to the wrong app), and falls back to request.base_url as before.
Under that root the fiat path now parameterises the hosted checkout via
`extra["checkout"]` (lnbits StripeCheckoutOptions): success_url
`/events/{id}?checkout=success&tickets=<ids>`, cancel_url
`/events/{id}?checkout=cancelled`, customer_email, an event-named line
item and event_id/quantity/ticket_ids metadata. Ticket ids are minted
before the invoice so the success URL can carry them (the payment_hash
only exists afterwards). ticket_base_url on the rows uses the same root,
so the emailed link lands in the webapp when the webapp was the client.

Purchases are gated on `effective_payment_methods(event)` (checked after
the free-ticket short-circuit, which charges nothing on any rail).

New anonymous `GET /events/api/v1/qr/{ticket_id}` returns a PNG of
`ticket://<id>` — port of upstream v1.6.8's endpoint without ticket-image
compositing — built at error-correction H with the instance QR logo
(`lnbits_qr_logo`) pasted in the centre, matching the client-side QRs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:53:05 +02:00