feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8) #36

Merged
padreug merged 7 commits from feat/guest-checkout-email into main 2026-09-07 09:27:51 +00:00
5 changed files with 94 additions and 38 deletions
Showing only changes of commit c2d9a96239 - Show all commits

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
Padreug 2026-09-06 19:53:05 +02:00

View file

@ -17,7 +17,7 @@ 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 from .models import Event, effective_payment_methods
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at from .nostr_timestamp import monotonic_created_at
@ -110,6 +110,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
tags.append(["tickets_allow_fiat", "true"]) tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency: if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency]) tags.append(["tickets_fiat_currency", event.fiat_currency])
# Rails the organizer accepts, resolved through the same helper 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
# round-trip. Comma-separated, lowercase.
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
# 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

View file

@ -35,13 +35,29 @@ window.PageEventsDisplay = {
async created() { async created() {
this.eventId = this.$route.params.id this.eventId = this.$route.params.id
this.event = await this.getEvent() this.event = await this.getEvent()
// Default to the first rail the organizer accepts (a card-only event
// must not submit "lightning").
this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning'
}, },
computed: { computed: {
formatDescription() { formatDescription() {
return LNbits.utils.convertMarkdown(this.event?.info || '') return LNbits.utils.convertMarkdown(this.event?.info || '')
}, },
paymentMethods() {
// Mirrors `effective_payment_methods` on the backend: an explicit
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
const explicit = this.event?.extra?.payment_methods || []
if (explicit.length) return explicit
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
},
allowFiatCheckout() { allowFiatCheckout() {
return Boolean(this.event?.allow_fiat) return this.paymentMethods.includes('fiat')
},
paymentMethodOptions() {
return this.paymentMethods.map(method => ({
value: method,
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
}))
}, },
fiatCheckoutLabel() { fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat' if (!this.allowFiatCheckout) return 'Fiat'
@ -78,7 +94,8 @@ 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.payment_method = 'lightning' this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
}, },
closeReceiveDialog() { closeReceiveDialog() {
@ -112,7 +129,8 @@ 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.payment_method = 'lightning' this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Sent, thank you!', message: 'Sent, thank you!',

View file

@ -90,20 +90,18 @@
: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>
<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="allowFiatCheckout" class="col-auto"> <div v-if="paymentMethods.length > 1" class="col-auto">
<q-option-group <q-option-group
v-model="formDialog.data.payment_method" v-model="formDialog.data.payment_method"
inline inline
:options="[ :options="paymentMethodOptions"
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
></q-option-group> ></q-option-group>
</div> </div>
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'"> <div
:class="
paymentMethods.length > 1 ? 'col-12 col-md-3' : 'col-12'
"
>
<q-input <q-input
filled filled
dense dense

View file

@ -156,6 +156,7 @@ window.PageEvents = {
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
extra: { extra: {
payment_methods: ['lightning'],
promo_codes: [], promo_codes: [],
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
@ -271,12 +272,7 @@ window.PageEvents = {
}, },
saveSettings() { saveSettings() {
LNbits.api LNbits.api
.request( .request('PUT', '/events/api/v1/events/settings', null, this.settings)
'PUT',
'/events/api/v1/events/settings',
null,
this.settings
)
.then(() => { .then(() => {
Quasar.Notify.create({type: 'positive', message: 'Settings saved'}) Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
}) })
@ -326,7 +322,7 @@ window.PageEvents = {
LNbits.utils LNbits.utils
.confirmDialog( .confirmDialog(
'Re-emit every approved event to Nostr relays? This is safe ' + 'Re-emit every approved event to Nostr relays? This is safe ' +
'to run multiple times but generates one event per approved row.' 'to run multiple times but generates one event per approved row.'
) )
.onOk(() => { .onOk(() => {
this.republishing = true this.republishing = true
@ -351,9 +347,7 @@ window.PageEvents = {
}, },
republishMyEvents() { republishMyEvents() {
LNbits.utils LNbits.utils
.confirmDialog( .confirmDialog('Re-emit your approved events to Nostr relays?')
'Re-emit your approved events to Nostr relays?'
)
.onOk(() => { .onOk(() => {
this.republishingMine = true this.republishingMine = true
LNbits.api LNbits.api
@ -420,6 +414,17 @@ window.PageEvents = {
code: code.code.trim().toUpperCase() code: code.code.trim().toUpperCase()
})) }))
} }
const methods = data.extra?.payment_methods || []
if (methods.length === 0) {
Quasar.Notify.create({
type: 'warning',
message: 'Select at least one payment method.'
})
return
}
// allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat')
if (!this.isFiatCurrency(data.currency)) { if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) { if (!data.allow_fiat) {
data.fiat_currency = 'GBP' data.fiat_currency = 'GBP'
@ -439,6 +444,15 @@ window.PageEvents = {
const end = this.splitDateTime(data.event_end_date) const end = this.splitDateTime(data.event_end_date)
this.formDialog.data = { this.formDialog.data = {
...data, ...data,
extra: {
...(data.extra || {}),
// Events created before extra.payment_methods existed carry an
// empty list; show the rails the backend actually accepts for
// them (Lightning always, fiat when allow_fiat).
payment_methods: data.extra?.payment_methods?.length
? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
},
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,
@ -454,6 +468,7 @@ window.PageEvents = {
event_end_day: '', event_end_day: '',
event_end_time: '', event_end_time: '',
extra: { extra: {
payment_methods: ['lightning'],
conditional: false, conditional: false,
min_tickets: 1, min_tickets: 1,
email_notifications: false, email_notifications: false,
@ -473,6 +488,7 @@ window.PageEvents = {
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
extra: { extra: {
payment_methods: ['lightning'],
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
promo_codes: [], promo_codes: [],

View file

@ -20,10 +20,10 @@
<div class="col"> <div class="col">
<span class="text-subtitle2">Republish to Nostr</span> <span class="text-subtitle2">Republish to Nostr</span>
<div class="text-caption text-grey-7" style="color: #aaa"> <div class="text-caption text-grey-7" style="color: #aaa">
Re-emit every approved event so connected clients pick Re-emit every approved event so connected clients pick up the
up the latest tag set. Useful after the extension latest tag set. Useful after the extension publisher changes
publisher changes (e.g. new tickets_* tags) so existing (e.g. new tickets_* tags) so existing events don't need a
events don't need a per-event edit. per-event edit.
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
@ -56,8 +56,8 @@
></q-btn> ></q-btn>
</div> </div>
<div class="text-caption q-mt-sm" style="color: #aaa"> <div class="text-caption q-mt-sm" style="color: #aaa">
Re-emit your approved events to Nostr relays. Useful after Re-emit your approved events to Nostr relays. Useful after a
a publisher upgrade or if a relay dropped your events. publisher upgrade or if a relay dropped your events.
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -228,7 +228,13 @@
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge <q-badge
v-if="col.name === 'status'" v-if="col.name === 'status'"
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'" :color="
col.value === 'approved'
? 'green'
: col.value === 'proposed'
? 'orange'
: 'red'
"
:label="col.value" :label="col.value"
></q-badge> ></q-badge>
<span v-else v-text="col.value"></span> <span v-else v-text="col.value"></span>
@ -399,7 +405,13 @@
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge <q-badge
v-if="col.name === 'status'" v-if="col.name === 'status'"
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'" :color="
col.value === 'approved'
? 'green'
: col.value === 'proposed'
? 'orange'
: 'red'
"
:label="col.value" :label="col.value"
></q-badge> ></q-badge>
<span v-else v-text="col.value"></span> <span v-else v-text="col.value"></span>
@ -587,15 +599,22 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<q-toggle <div class="q-mt-sm">
v-model="formDialog.data.allow_fiat" <div class="text-caption text-grey-7">Payment methods *</div>
label="Allow fiat checkout" <q-option-group
left-label v-model="formDialog.data.extra.payment_methods"
hint="Lets attendees pay through a configured fiat provider using the event currency." type="checkbox"
></q-toggle> inline
:options="paymentMethodOptions"
></q-option-group>
<div class="text-caption text-grey-7">
Card / fiat checkout goes through the fiat provider configured on
this LNbits instance. Untick Lightning for a card-only event.
</div>
</div>
<q-select <q-select
v-if=" v-if="
formDialog.data.allow_fiat && acceptsFiat &&
['sat', 'sats'].includes( ['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase() (formDialog.data.currency || '').toLowerCase()
) )