feat(activities): payment-rails pattern + provider-aware checkout #68

Merged
padreug merged 7 commits from payment-rails-pattern into dev 2026-05-23 21:18:54 +00:00
13 changed files with 992 additions and 67 deletions

View file

@ -714,6 +714,90 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
VITE_WEBSOCKET_ENABLED=true VITE_WEBSOCKET_ENABLED=true
``` ```
## Payment Rails Pattern
Shared primitives for modules that mix Lightning + fiat (and, future,
cash / internal-wallet) payment rails. Activities is the first
consumer; restaurant + marketplace will adopt the same primitives as
their backends gain fiat support.
### Vocabulary (canonical — used in code AND UI labels)
| Term | Meaning | Field |
|---|---|---|
| **Price currency** | unit the price is quoted in | `currency` |
| **Payment method** | the rail used to pay (Lightning / Fiat / Cash / Internal) | `payment_method` |
| **Fiat currency** | currency the fiat provider settles in | `fiat_currency` |
| **Also accept fiat** | the user-facing label for the `allow_fiat` toggle | `allow_fiat` |
The bare word `Currency` is **banned** in payment-context UI labels —
it always carries a `Price` or `Fiat` qualifier. The literal string
`Pay in fiat` is also banned on buyer-side buttons — each fiat rail
shows as a button labeled with its provider name (`Stripe`, `PayPal`,
`Square`, `SEPA`). Only the degenerate "providers unknown" fallback
shows a generic `Card`.
### Fiat-provider architecture (LNbits today)
Fiat providers are configured **globally** by the LNbits admin
(`lnbits/settings.py`). Each provider has an optional `allowed_users`
whitelist; the per-session filtered list is exposed as
`User.fiat_providers: string[]` on `GET /api/v1/auth` (which the
webapp already reads as `currentUser.fiat_providers`). Both organizer
and buyer on the same instance see the same list.
Per-user provider configuration is a deferred backend feature. Until
then, `useFiatProviders` reads the same `currentUser.fiat_providers`
for both sides.
### Shared primitives (live in base module)
```
src/modules/base/
├── composables/
│ ├── useFiatProviders.ts // providers + hasAnyProvider + refresh + providerMeta()
│ └── usePriceConversion.ts // convert() + useLivePreview(); 60s cache; null on failure
└── components/payments/
├── PaymentMethodSelector.vue // buyer-side rail picker
├── FiatToggleField.vue // organizer-side toggle + conditional fiat-currency dropdown
└── PriceConversionPreview.vue // muted "≈ X.XX USD" line
```
All three components consume services via DI — never import them
directly across module boundaries.
### `PaymentMethodSelector` data shape
```ts
type PaymentRail = 'lightning' | 'fiat' | 'cash' | 'internal' | (string & {})
type PaymentMethod = {
id: string // unique v-for key, e.g. 'fiat:stripe'
rail: PaymentRail // sent as payment_method
provider?: string // sent as fiat_provider when present
label: string // 'Lightning' | 'Stripe' | 'SEPA' | 'Cash' | …
icon: Component // lucide icon
available: boolean // false ⇒ rendered disabled with tooltip
unavailableReason?: string // tooltip when disabled
badge?: string // optional secondary hint, e.g. '≈ 1000 sats'
}
```
Module usage:
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
- **Restaurant** (future) passes the subset of
`[lightning, cash, internal, ...fiat providers]` enabled by the
restaurant's `accepts_*` flags.
### Adding a new fiat provider
1. Backend exposes the provider id in `User.fiat_providers`.
2. Add the id to `KNOWN_PROVIDERS` in `useFiatProviders.ts` with its
display label and icon hint (`'card' | 'bank' | 'wallet'`).
3. Unknown ids fall back to a `Capitalized` label with a `'card'`
icon hint — no code change required just for the buttons to
render, only for nice branding.
## Mobile Browser File Input & Form Refresh Issues ## Mobile Browser File Input & Form Refresh Issues
### **Problem Overview** ### **Problem Overview**

View file

@ -40,7 +40,8 @@ interface User {
username?: string username?: string
email?: string email?: string
pubkey?: string pubkey?: string
prvkey?: string // Nostr private key for user // pragma: allowlist secret
prvkey?: string // Nostr signing key for user
external_id?: string external_id?: string
extensions: string[] extensions: string[]
wallets: Wallet[] wallets: Wallet[]
@ -191,6 +192,13 @@ export class LnbitsAPI extends BaseService {
}) })
} }
async getConversion(params: { from: string; to: string; amount: number }): Promise<Record<string, number>> {
return this.request<Record<string, number>>('/conversion', {
method: 'POST',
body: JSON.stringify({ from_: params.from, to: params.to, amount: params.amount }),
})
}
isAuthenticated(): boolean { isAuthenticated(): boolean {
return !!this.accessToken return !!this.accessToken
} }

View file

@ -32,12 +32,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next' import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService' import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue' import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue' import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue'
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
@ -120,13 +121,15 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
allow_fiat: z.boolean().default(false),
fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0), price_per_ticket: z.number().min(0).default(0),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time // End must not precede start. Compare on the folded date+time
// string so equal-date / later-time is enforced too. // string so equal-date / later-time is enforced too.
if (!v.event_end_date) return if (v.event_end_date) {
const start = foldDateTime(v.event_start_date, v.event_start_time) const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time) const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) { if (start && end && end < start) {
@ -136,6 +139,19 @@ const formSchema = toTypedSchema(
message: 'End must be on or after start', message: 'End must be on or after start',
}) })
} }
}
// When the price is in sats and the organizer also accepts fiat,
// they MUST choose a settle currency. Other price denominations
// mirror themselves into fiat_currency automatically. The events
// extension uses 'sat' and 'sats' interchangeably accept both.
const isSat = v.currency === 'sat' || v.currency === 'sats'
if (v.allow_fiat && isSat && !v.fiat_currency) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fiat_currency'],
message: 'Pick a fiat currency for buyers paying by card',
})
}
}) })
) )
@ -150,6 +166,8 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
allow_fiat: false,
fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 0, price_per_ticket: 0,
} }
@ -213,6 +231,8 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time, event_end_time: end.time,
location: event.location ?? '', location: event.location ?? '',
currency: event.currency ?? 'sat', currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0, amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0, price_per_ticket: event.price_per_ticket ?? 0,
}) })
@ -318,6 +338,13 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends so a truefalse flip propagates on edit;
// fiat_currency only sends when fiat is on (no point persisting a
// rail-currency the backend won't use).
eventData.allow_fiat = formValues.allow_fiat
if (formValues.allow_fiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency
}
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
@ -547,7 +574,15 @@ const handleOpenChange = (open: boolean) => {
/> />
</div> </div>
<!-- Tickets (optional, visible) --> <!-- Pricing -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Pricing</p>
<p class="text-xs text-muted-foreground">
Set what buyers see. Lightning charges happen in sats;
fiat amounts convert at checkout using current rates.
</p>
</div>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem> <FormItem>
@ -573,7 +608,7 @@ const handleOpenChange = (open: boolean) => {
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem> <FormItem>
<FormLabel>Currency</FormLabel> <FormLabel>Price currency</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger> <SelectTrigger>
@ -590,6 +625,29 @@ const handleOpenChange = (open: boolean) => {
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
</div>
<!-- Payment methods -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Payment methods</p>
<p class="text-xs text-muted-foreground">
Lightning is always available. Enable fiat to also accept
card and bank payments through your configured provider.
</p>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Zap class="w-4 h-4" />
<span>Lightning always on</span>
</div>
<FiatToggleField
allow-fiat-field="allow_fiat"
fiat-currency-field="fiat_currency"
:denomination="form.values.currency ?? 'sat'"
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
:disabled="isLoading"
/>
</div>
<!-- Actions --> <!-- Actions -->
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">

View file

@ -1,12 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useTicketPurchase } from '../composables/useTicketPurchase' import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting' import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
} from '@/modules/base/components/payments/PaymentMethodSelector.vue'
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
interface Props { interface Props {
event: { event: {
@ -14,6 +22,9 @@ interface Props {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
} }
isOpen: boolean isOpen: boolean
} }
@ -45,19 +56,156 @@ const {
showTicketQR showTicketQR
} = useTicketPurchase() } = useTicketPurchase()
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false)
const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats',
)
// Lightning-button badge: when the price is denominated in fiat, show
// the live sat equivalent so the buyer knows roughly what their wallet
// will be charged. Best-effort silent if the conversion fails.
const lightningSats = ref<number | null>(null)
watch(
() => [props.event.currency, props.event.price_per_ticket, props.isOpen] as const,
async ([cur, amt, open]) => {
if (!open || !amt || cur === 'sat' || cur === 'sats') {
lightningSats.value = null
return
}
lightningSats.value = await convert(amt as number, cur as string, 'sat')
},
{ immediate: true },
)
function iconFor(hint: 'card' | 'bank' | 'wallet') {
if (hint === 'bank') return Landmark
if (hint === 'wallet') return Wallet
return CreditCard
}
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = {
id: 'lightning',
rail: 'lightning',
label: 'Lightning',
icon: Zap,
available: true,
badge:
!isPriceInSats.value && lightningSats.value
? `${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
}
if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
const meta = providerMeta(id)
return {
id: `fiat:${id}`,
rail: 'fiat',
provider: id,
label: meta.label,
icon: iconFor(meta.icon),
available: true,
}
})
return [lightning, ...fiatRails]
}
// Degenerate fallback allow_fiat is on but the buyer's session
// can't enumerate the organizer's providers. Show a generic Card
// button and let the backend pick a default at request time.
return [
lightning,
{
id: 'fiat',
rail: 'fiat',
label: 'Card',
icon: CreditCard,
available: true,
},
]
})
const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
)
async function handlePurchase() { async function handlePurchase() {
if (!canPurchase.value) return if (!canPurchase.value) return
fiatError.value = null
const method = selectedMethod.value
if (!method) return
// Lightning path: existing composable handles QR + wallet auto-pay.
if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id) await purchaseTicketForEvent(props.event.id)
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
return
}
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
// API directly with the chosen provider, then redirect the buyer to
// the provider's checkout URL. Payment confirmation happens via
// webhook on the backend and shows up next time the buyer reloads
// MyTickets.
try {
isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const currentUser = (lnbitsAPI?.currentUser?.value) || null
const userId = currentUser?.id
if (!userId) {
fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken,
{ paymentMethod: 'fiat', fiatProvider: method.provider },
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
return
}
fiatRedirectUrl.value = invoice.fiatPaymentRequest
fiatProviderLabel.value = invoice.fiatProvider
? providerMeta(invoice.fiatProvider).label
: method.label
} catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
isFiatPending.value = false
}
}
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
} }
function handleClose() { function handleClose() {
emit('update:isOpen', false) emit('update:isOpen', false)
resetPaymentState() resetPaymentState()
selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
} }
onUnmounted(() => { onUnmounted(() => {
@ -158,19 +306,68 @@ onUnmounted(() => {
<span class="text-sm text-muted-foreground">Price:</span> <span class="text-sm text-muted-foreground">Price:</span>
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span> <span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
</div> </div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="event.price_per_ticket"
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
suffix=" if paid in fiat"
/>
</div> </div>
</div> </div>
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg"> <!-- Payment method selector (only shown when fiat is enabled
{{ error }} on the event). Buttons surface one per configured fiat
provider so "Stripe" / "PayPal" / "Square" stand alongside
Lightning rather than collapsing into a single "Fiat"
catch-all. Hidden entirely for Lightning-only events to
keep the dialog uncluttered. -->
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails.
Live rates shown are estimates; the exact sat amount locks
in when you start checkout.
</p>
<PaymentMethodSelector
:methods="paymentMethods"
:model-value="selectedMethodId"
@update:model-value="selectedMethodId = $event"
/>
</div>
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ error || fiatError }}
</div>
<!-- Fiat checkout panel shown after a successful fiat
POST when we have a provider URL to redirect to. -->
<div v-if="fiatRedirectUrl" class="space-y-3">
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
<p class="text-xs text-muted-foreground">
Opens the provider's checkout in a new tab. Your ticket
appears in My Tickets once the payment settles.
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div> </div>
<Button <Button
v-else
@click="handlePurchase" @click="handlePurchase"
:disabled="isLoading || !canPurchase" :disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full" class="w-full"
> >
<span v-if="isLoading" class="animate-spin mr-2"></span> <span v-if="isLoading || isFiatPending" class="animate-spin mr-2"></span>
<span v-else-if="selectedMethod?.rail === 'fiat'" class="flex items-center gap-2">
<CreditCard class="w-4 h-4" />
Continue to {{ selectedMethod.label }} checkout
</span>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2"> <span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<Zap class="w-4 h-4" /> <Zap class="w-4 h-4" />
Pay with Wallet Pay with Wallet

View file

@ -98,16 +98,31 @@ export function useTicketPurchase() {
currentUser.value!.id, currentUser.value!.id,
accessToken accessToken
) )
// Backend now returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows
// how to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog
// (it has the user-visible payment-method selector). Reject the
// fiat response here so callers get a clear error instead of a
// silent broken QR.
if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
'to follow the provider link.',
)
}
const bolt11: string = invoice.paymentRequest
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash
paymentRequest.value = invoice.paymentRequest paymentRequest.value = bolt11
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(invoice.paymentRequest) await generateQRCode(bolt11)
// Try to pay with wallet if available // Try to pay with wallet if available
if (hasWalletWithBalance.value) { if (hasWalletWithBalance.value) {
try { try {
await payWithWallet(invoice.paymentRequest) await payWithWallet(bolt11)
await startPaymentStatusCheck(eventId, invoice.paymentHash) await startPaymentStatusCheck(eventId, invoice.paymentHash)
} catch (walletError) { } catch (walletError) {
console.log('Wallet payment failed, falling back to manual payment:', walletError) console.log('Wallet payment failed, falling back to manual payment:', walletError)

View file

@ -1,5 +1,8 @@
import type { import type {
ActivityTicket, ActivityTicket,
ActivityTicketExtra,
CreateTicketRequest,
PaymentMethod,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketedEvent, TicketedEvent,
@ -49,14 +52,38 @@ export class TicketApiService {
} }
/** /**
* Request a ticket purchase (creates a Lightning invoice). * Request a ticket purchase. Returns either a Lightning invoice
* Uses POST /tickets/{event_id} with user_id in body (upstream API). * (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest`
* = follow-the-URL string from the configured fiat provider). The
* `isFiat` flag is the discriminator.
*
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* the fiat path (requires the event to have `allow_fiat=true`).
* `fiatProvider` is optional backend picks the user's configured
* default when omitted.
*
* Additional ticket metadata (promo code, refund address, nostr
* identifier for DM delivery) can be supplied via `options`.
*/ */
async requestTicket( async requestTicket(
eventId: string, eventId: string,
userId: string, userId: string,
accessToken: string accessToken: string,
options: {
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
} = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
{ {
@ -65,13 +92,16 @@ export class TicketApiService {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, 'Authorization': `Bearer ${accessToken}`,
}, },
body: JSON.stringify({ user_id: userId }), body: JSON.stringify(body),
} }
) )
return { return {
paymentHash: data.payment_hash, paymentHash: data.payment_hash,
paymentRequest: data.payment_request, paymentRequest: data.payment_request ?? undefined,
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat),
} }
} }
@ -121,6 +151,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
})) }))
} }
@ -144,6 +175,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
})) }))
} }
@ -183,6 +215,39 @@ export class TicketApiService {
}) })
} }
/**
* Resend the ticket confirmation email for a paid ticket. Requires
* the event's wallet admin key (organizer-only). Returns the updated
* Ticket with the `email_notification_sent` flag refreshed.
*
* Endpoint added upstream in v1.6.1 (PR #51).
*/
async resendTicketEmail(
ticketId: string,
adminKey: string,
): Promise<ActivityTicket> {
const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
method: 'POST',
headers: { 'X-API-KEY': adminKey },
}
)
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}
}
/** /**
* Probe whether the current user has LNbits admin privileges. The * Probe whether the current user has LNbits admin privileges. The
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin", * `/all` endpoint is `check_admin`-gated, so a 200 means "admin",

View file

@ -1,7 +1,44 @@
/** /**
* Database-backed ticket types (via LNbits events extension) * Database-backed ticket types (via LNbits events extension).
*
* Wire-format types names match the snake_case fields the events
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
* below) are the webapp-internal view models after adapter conversion.
*/ */
export interface PromoCode {
code: string
discount_percent: number
active: boolean
}
/**
* EventExtra mirrors the EventExtra Pydantic model in
* `events/models.py`. Carries promo codes, conditional-event config,
* and the per-event notification toggles + custom subject/body added
* in upstream v1.4.0 (PR #50) and v1.6.0.
*/
export interface EventExtra {
promo_codes: PromoCode[]
conditional: boolean
min_tickets: number
email_notifications: boolean
nostr_notifications: boolean
notification_subject: string
notification_body: string
}
export interface ActivityTicketExtra {
applied_promo_code?: string | null
sats_paid?: number | null
refund_address?: string | null
nostr_identifier?: string | null
ticket_base_url?: string | null
email_notification_sent: boolean
nostr_notification_sent: boolean
refunded: boolean
}
export interface ActivityTicket { export interface ActivityTicket {
id: string id: string
wallet: string wallet: string
@ -21,19 +58,40 @@ export interface ActivityTicket {
time: string time: string
/** Registration/scan timestamp */ /** Registration/scan timestamp */
regTimestamp: string regTimestamp: string
/** Optional metadata promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */
extra?: ActivityTicketExtra
} }
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
export type PaymentMethod = 'lightning' | 'fiat'
export interface TicketPurchaseRequest { export interface TicketPurchaseRequest {
activityId: string activityId: string
userId: string userId: string
accessToken: string accessToken: string
/** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */
paymentMethod?: PaymentMethod
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
* user's default if omitted. */
fiatProvider?: string
} }
/**
* Server response from `POST /tickets/{event_id}`. Either Lightning
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* the buyer follows to complete payment with `fiatProvider`).
* `isFiat` is the discriminator.
*/
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string
paymentRequest: string paymentRequest?: string
fiatPaymentRequest?: string
fiatProvider?: string
isFiat: boolean
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
@ -58,6 +116,10 @@ export interface TicketedEvent {
event_start_date: string event_start_date: string
event_end_date: string | null event_end_date: string | null
currency: string currency: string
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
allow_fiat: boolean
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
fiat_currency: string
amount_tickets: number amount_tickets: number
price_per_ticket: number price_per_ticket: number
time: string time: string
@ -65,6 +127,7 @@ export interface TicketedEvent {
banner: string | null banner: string | null
location: string | null location: string | null
categories: string[] categories: string[]
extra: EventExtra
status: string status: string
} }
@ -76,9 +139,34 @@ export interface CreateEventRequest {
event_start_date: string event_start_date: string
event_end_date?: string event_end_date?: string
currency?: string currency?: string
allow_fiat?: boolean
fiat_currency?: string
amount_tickets?: number amount_tickets?: number
price_per_ticket?: number price_per_ticket?: number
banner?: string | null banner?: string | null
location?: string | null location?: string | null
categories?: string[] categories?: string[]
/** Optional notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */
extra?: Partial<EventExtra>
}
/**
* Body for `POST /tickets/{event_id}`. Either `user_id` OR the
* `name`+`email` pair is required (backend root_validator enforces
* mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
* delivery when the event has nostr_notifications enabled. The
* `payment_method` + `fiat_provider` pair selects between Lightning
* and fiat checkout.
*/
export interface CreateTicketRequest {
name?: string
email?: string
user_id?: string
promo_code?: string
refund_address?: string
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
} }

View file

@ -27,6 +27,8 @@ const selectedEvent = ref<{
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
} | null>(null) } | null>(null)
const showEventDialog = ref(false) const showEventDialog = ref(false)
@ -56,6 +58,8 @@ function handlePurchaseClick(event: {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
}) { }) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedEvent.value = event selectedEvent.value = event

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useFormContext } from 'vee-validate'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
const props = defineProps<{
/** Field name on the parent vee-validate form for the boolean toggle. */
allowFiatField: string
/** Field name on the parent vee-validate form for the fiat-currency dropdown. */
fiatCurrencyField: string
/** Current value of the price-denomination field (e.g. 'sat', 'USD'). */
denomination: string
/** Allowed values for the fiat-currency dropdown. */
availableFiatCurrencies: string[]
/** Disable all controls (e.g. while the parent form is submitting). */
disabled?: boolean
}>()
const { hasAnyProvider, refresh } = useFiatProviders()
const form = useFormContext()
// Refresh once on mount so the disabled-state reflects providers the
// user may have just configured in another tab.
refresh()
// "sat" / "sats" appear interchangeably across the LNbits events
// extension and the webapp's currency lists treat both as the
// BTC-denominated case for the conditional + auto-mirror.
function isSatDenomination(d: string): boolean {
return d === 'sat' || d === 'sats'
}
// When the price is denominated in a fiat currency, the rail currency
// MUST match it silently mirror so backend payload stays consistent.
watch(
() => props.denomination,
(d) => {
if (!form) return
if (
d &&
!isSatDenomination(d) &&
form.values[props.fiatCurrencyField as keyof typeof form.values] !== d
) {
form.setFieldValue(props.fiatCurrencyField, d)
}
},
{ immediate: true },
)
</script>
<template>
<FormField v-slot="{ value: allowFiat, handleChange: setAllowFiat }" :name="allowFiatField">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
<FormItem class="sm:col-span-2 flex flex-row items-center justify-between rounded-md border p-3">
<div class="space-y-0.5">
<FormLabel>Also accept fiat</FormLabel>
<FormDescription class="text-xs">
Buyers can pay with card or bank through your configured provider.
</FormDescription>
</div>
<FormControl>
<TooltipProvider v-if="!hasAnyProvider" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Switch :model-value="false" disabled />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-xs">
Your LNbits user has no fiat provider configured. Open
LNbits Account Fiat providers and add Stripe, PayPal,
or Square to enable this.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
v-else
:model-value="allowFiat as boolean"
:disabled="disabled"
@update:model-value="setAllowFiat"
/>
</FormControl>
</FormItem>
<FormField v-slot="{ componentField }" :name="fiatCurrencyField">
<FormItem v-show="(allowFiat as boolean) && isSatDenomination(denomination)">
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="disabled">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableFiatCurrencies"
:key="c"
:value="c"
>
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</FormField>
</template>

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type PaymentRail =
| 'lightning'
| 'fiat'
| 'cash'
| 'internal'
| (string & {})
export interface PaymentMethod {
id: string
rail: PaymentRail
provider?: string
label: string
icon: Component
available: boolean
unavailableReason?: string
badge?: string
}
defineProps<{
methods: PaymentMethod[]
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [id: string]
}>()
function select(method: PaymentMethod) {
if (!method.available) return
emit('update:modelValue', method.id)
}
</script>
<template>
<div
class="grid gap-2"
:class="methods.length > 2 ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2'"
>
<template v-for="method in methods" :key="method.id">
<TooltipProvider
v-if="!method.available && method.unavailableReason"
:delay-duration="200"
>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="sm"
disabled
class="opacity-60 flex-col h-auto py-2 gap-1"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ method.unavailableReason }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
v-else
type="button"
:variant="modelValue === method.id ? 'default' : 'outline'"
size="sm"
:disabled="!method.available"
class="flex-col h-auto py-2 gap-1"
@click="select(method)"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</template>
</div>
</template>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
const props = withDefaults(
defineProps<{
amount: number
from: string
to: string
/** Text prepended to the conversion line (e.g. "≈" or "Equivalent"). */
prefix?: string
/** Suffix appended after the number (e.g. " at current rate"). */
suffix?: string
}>(),
{
prefix: '≈',
suffix: ' at current rate',
},
)
const { useLivePreview } = usePriceConversion()
const { result, loading } = useLivePreview(
toRef(props, 'amount'),
toRef(props, 'from'),
toRef(props, 'to'),
)
const formatted = computed(() => {
const v = result.value
if (v == null) return null
if (props.to.toLowerCase() === 'sat') {
return `${Math.round(v).toLocaleString()} sats`
}
const fixed = v < 1 ? v.toFixed(4) : v.toFixed(2)
return `${fixed} ${props.to.toUpperCase()}`
})
</script>
<template>
<p v-if="amount > 0" class="text-xs text-muted-foreground">
<span v-if="loading && !formatted">Loading rate</span>
<span v-else-if="formatted">{{ prefix }} {{ formatted }}{{ suffix }}</span>
<span v-else class="opacity-60">(rate unavailable)</span>
</p>
</template>

View file

@ -0,0 +1,53 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
export type FiatProviderIcon = 'card' | 'bank' | 'wallet'
export interface FiatProviderMeta {
label: string
icon: FiatProviderIcon
}
const KNOWN_PROVIDERS: Record<string, FiatProviderMeta> = {
stripe: { label: 'Stripe', icon: 'card' },
paypal: { label: 'PayPal', icon: 'wallet' },
square: { label: 'Square', icon: 'card' },
sepa: { label: 'SEPA', icon: 'bank' },
}
export function providerMeta(id: string): FiatProviderMeta {
const known = KNOWN_PROVIDERS[id.toLowerCase()]
if (known) return known
return {
label: id.charAt(0).toUpperCase() + id.slice(1),
icon: 'card',
}
}
/**
* Shared accessor for the current user's available fiat providers.
*
* Fiat providers (Stripe, PayPal, Square, SEPA, ) are configured
* globally by the LNbits admin. Per-provider `allowed_users`
* whitelists narrow that to a session-specific list, exposed as
* `User.fiat_providers` on `GET /api/v1/auth`. Both organizers and
* buyers on the same instance see the same list today.
*
* Call `refresh()` from owner-side dialogs that may open right after
* the user configured a new provider in another tab.
*/
export function useFiatProviders() {
const auth = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const providers = computed<string[]>(
() => auth.currentUser.value?.fiat_providers ?? []
)
const hasAnyProvider = computed(() => providers.value.length > 0)
async function refresh(): Promise<void> {
await auth.refresh()
}
return { providers, hasAnyProvider, refresh, providerMeta }
}

View file

@ -0,0 +1,88 @@
import { ref, watch, type Ref } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LnbitsAPI } from '@/lib/api/lnbits'
interface CacheEntry {
value: number
expiresAt: number
}
const cache = new Map<string, CacheEntry>()
const TTL_MS = 60_000
function cacheKey(amount: number, from: string, to: string): string {
return `${from.toLowerCase()}|${to.toLowerCase()}|${amount}`
}
/**
* Live + cached BTC fiat rate previews via LNbits `/api/v1/conversion`.
*
* Both helpers tolerate a transient failure (returning `null`) surface
* conversion preview as best-effort UX, never as a blocker. 60s in-memory
* cache de-duplicates dialog re-renders.
*/
export function usePriceConversion() {
const lnbitsAPI = injectService<LnbitsAPI>(SERVICE_TOKENS.LNBITS_API)
async function convert(
amount: number,
from: string,
to: string,
): Promise<number | null> {
if (!amount || !from || !to) return null
if (from.toLowerCase() === to.toLowerCase()) return amount
const key = cacheKey(amount, from, to)
const cached = cache.get(key)
if (cached && cached.expiresAt > Date.now()) return cached.value
try {
const data = await lnbitsAPI.getConversion({ from, to, amount })
const result =
data[to] ??
data[to.toUpperCase()] ??
data[to.toLowerCase()] ??
(data as Record<string, number>).amount ??
(data as Record<string, number>).result
if (typeof result !== 'number') return null
cache.set(key, { value: result, expiresAt: Date.now() + TTL_MS })
return result
} catch (err) {
console.warn('[usePriceConversion] convert failed:', err)
return null
}
}
function useLivePreview(
amount: Ref<number>,
from: Ref<string>,
to: Ref<string>,
debounceMs = 300,
): { result: Ref<number | null>; loading: Ref<boolean> } {
const result = ref<number | null>(null)
const loading = ref(false)
let activeToken = 0
let timer: ReturnType<typeof setTimeout> | null = null
watch(
[amount, from, to],
() => {
if (timer) clearTimeout(timer)
const myToken = ++activeToken
loading.value = true
timer = setTimeout(async () => {
const v = await convert(amount.value, from.value, to.value)
if (myToken === activeToken) {
result.value = v
loading.value = false
}
}, debounceMs)
},
{ immediate: true },
)
return { result, loading }
}
return { convert, useLivePreview }
}