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
This commit is contained in:
Padreug 2026-09-06 19:53:05 +02:00
commit c2d9a96239
5 changed files with 94 additions and 38 deletions

View file

@ -17,7 +17,7 @@ from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import Event
from .models import Event, effective_payment_methods
from .nostr.event import NostrEvent
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"])
if 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
# whenever inventory changes (a ticket sells). Use a strictly-monotonic

View file

@ -35,13 +35,29 @@ window.PageEventsDisplay = {
async created() {
this.eventId = this.$route.params.id
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: {
formatDescription() {
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() {
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() {
if (!this.allowFiatCheckout) return 'Fiat'
@ -78,7 +94,8 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
},
closeReceiveDialog() {
@ -112,7 +129,8 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
Quasar.Notify.create({
type: 'positive',
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.`"
></q-input>
<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
v-model="formDialog.data.payment_method"
inline
:options="[
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
:options="paymentMethodOptions"
></q-option-group>
</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
filled
dense

View file

@ -156,6 +156,7 @@ window.PageEvents = {
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
payment_methods: ['lightning'],
promo_codes: [],
notification_subject: '',
notification_body: ''
@ -271,12 +272,7 @@ window.PageEvents = {
},
saveSettings() {
LNbits.api
.request(
'PUT',
'/events/api/v1/events/settings',
null,
this.settings
)
.request('PUT', '/events/api/v1/events/settings', null, this.settings)
.then(() => {
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
})
@ -326,7 +322,7 @@ window.PageEvents = {
LNbits.utils
.confirmDialog(
'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(() => {
this.republishing = true
@ -351,9 +347,7 @@ window.PageEvents = {
},
republishMyEvents() {
LNbits.utils
.confirmDialog(
'Re-emit your approved events to Nostr relays?'
)
.confirmDialog('Re-emit your approved events to Nostr relays?')
.onOk(() => {
this.republishingMine = true
LNbits.api
@ -420,6 +414,17 @@ window.PageEvents = {
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 (!data.allow_fiat) {
data.fiat_currency = 'GBP'
@ -439,6 +444,15 @@ window.PageEvents = {
const end = this.splitDateTime(data.event_end_date)
this.formDialog.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_time: start.time,
event_end_day: end.day,
@ -454,6 +468,7 @@ window.PageEvents = {
event_end_day: '',
event_end_time: '',
extra: {
payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false,
@ -473,6 +488,7 @@ window.PageEvents = {
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
payment_methods: ['lightning'],
email_notifications: false,
nostr_notifications: false,
promo_codes: [],

View file

@ -20,10 +20,10 @@
<div class="col">
<span class="text-subtitle2">Republish to Nostr</span>
<div class="text-caption text-grey-7" style="color: #aaa">
Re-emit every approved event so connected clients pick
up the latest tag set. Useful after the extension
publisher changes (e.g. new tickets_* tags) so existing
events don't need a per-event edit.
Re-emit every approved event so connected clients pick up the
latest tag set. Useful after the extension publisher changes
(e.g. new tickets_* tags) so existing events don't need a
per-event edit.
</div>
</div>
<div class="col-auto">
@ -56,8 +56,8 @@
></q-btn>
</div>
<div class="text-caption q-mt-sm" style="color: #aaa">
Re-emit your approved events to Nostr relays. Useful after
a publisher upgrade or if a relay dropped your events.
Re-emit your approved events to Nostr relays. Useful after a
publisher upgrade or if a relay dropped your events.
</div>
</q-card-section>
</q-card>
@ -228,7 +228,13 @@
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge
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"
></q-badge>
<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-badge
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"
></q-badge>
<span v-else v-text="col.value"></span>
@ -587,15 +599,22 @@
></q-input>
</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>
<div class="q-mt-sm">
<div class="text-caption text-grey-7">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">
Card / fiat checkout goes through the fiat provider configured on
this LNbits instance. Untick Lightning for a card-only event.
</div>
</div>
<q-select
v-if="
formDialog.data.allow_fiat &&
acceptsFiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)