diff --git a/CLAUDE.md b/CLAUDE.md index 2ef2826..b85fab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -714,6 +714,90 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]' 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 ### **Problem Overview** diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index c837861..1e1ecc9 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -40,7 +40,8 @@ interface User { username?: string email?: string pubkey?: string - prvkey?: string // Nostr private key for user + // pragma: allowlist secret + prvkey?: string // Nostr signing key for user external_id?: string extensions: string[] wallets: Wallet[] @@ -191,6 +192,13 @@ export class LnbitsAPI extends BaseService { }) } + async getConversion(params: { from: string; to: string; amount: number }): Promise> { + return this.request>('/conversion', { + method: 'POST', + body: JSON.stringify({ from_: params.from, to: params.to, amount: params.amount }), + }) + } + isAuthenticated(): boolean { return !!this.accessToken } diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index 7f39fc0..739e300 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -32,12 +32,13 @@ import { SelectTrigger, SelectValue, } 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 { injectService, SERVICE_TOKENS } from '@/core/di-container' import ImageUpload from '@/modules/base/components/ImageUpload.vue' import DatePicker from '@/modules/base/components/DatePicker.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 type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' @@ -120,20 +121,35 @@ const formSchema = toTypedSchema( event_end_time: z.string().optional().default(''), location: z.string().max(500).optional().default(''), 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), price_per_ticket: z.number().min(0).default(0), }) .superRefine((v, ctx) => { // End must not precede start. Compare on the folded date+time // string so equal-date / later-time is enforced too. - if (!v.event_end_date) return - const start = foldDateTime(v.event_start_date, v.event_start_time) - const end = foldDateTime(v.event_end_date, v.event_end_time) - if (start && end && end < start) { + if (v.event_end_date) { + const start = foldDateTime(v.event_start_date, v.event_start_time) + const end = foldDateTime(v.event_end_date, v.event_end_time) + if (start && end && end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['event_end_date'], + 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: ['event_end_date'], - message: 'End must be on or after start', + path: ['fiat_currency'], + message: 'Pick a fiat currency for buyers paying by card', }) } }) @@ -150,6 +166,8 @@ const form = useForm({ event_end_time: '', location: '', currency: 'sat', + allow_fiat: false, + fiat_currency: 'USD', amount_tickets: 0, price_per_ticket: 0, } @@ -213,6 +231,8 @@ async function populateFromEvent(event: TicketedEvent) { event_end_time: end.time, location: event.location ?? '', currency: event.currency ?? 'sat', + allow_fiat: event.allow_fiat ?? false, + fiat_currency: event.fiat_currency ?? 'USD', amount_tickets: event.amount_tickets ?? 0, price_per_ticket: event.price_per_ticket ?? 0, }) @@ -318,6 +338,13 @@ const onSubmit = form.handleSubmit(async (formValues) => { eventData.banner = null } if (formValues.currency) eventData.currency = formValues.currency + // allow_fiat always sends so a true→false 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.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value @@ -547,48 +574,79 @@ const handleOpenChange = (open: boolean) => { /> - -
- - - Tickets - - - - 0 = unlimited - - - + +
+
+

Pricing

+

+ Set what buyers see. Lightning charges happen in sats; + fiat amounts convert at checkout using current rates. +

+
+
+ + + Tickets + + + + 0 = unlimited + + + - - - Price - - - - 0 = free - - - + + + Price + + + + 0 = free + + + - - - Currency - - - - - - + + + Price currency + + + + + + +
+
+ + +
+
+

Payment methods

+

+ Lightning is always available. Enable fiat to also accept + card and bank payments through your configured provider. +

+
+
+ + Lightning — always on +
+
diff --git a/src/modules/activities/components/PurchaseTicketDialog.vue b/src/modules/activities/components/PurchaseTicketDialog.vue index 643e8e0..22eb1b8 100644 --- a/src/modules/activities/components/PurchaseTicketDialog.vue +++ b/src/modules/activities/components/PurchaseTicketDialog.vue @@ -1,12 +1,20 @@ + + diff --git a/src/modules/base/components/payments/PaymentMethodSelector.vue b/src/modules/base/components/payments/PaymentMethodSelector.vue new file mode 100644 index 0000000..a5fb3dd --- /dev/null +++ b/src/modules/base/components/payments/PaymentMethodSelector.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/modules/base/components/payments/PriceConversionPreview.vue b/src/modules/base/components/payments/PriceConversionPreview.vue new file mode 100644 index 0000000..17cdba3 --- /dev/null +++ b/src/modules/base/components/payments/PriceConversionPreview.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/modules/base/composables/useFiatProviders.ts b/src/modules/base/composables/useFiatProviders.ts new file mode 100644 index 0000000..ac28691 --- /dev/null +++ b/src/modules/base/composables/useFiatProviders.ts @@ -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 = { + 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(SERVICE_TOKENS.AUTH_SERVICE) + + const providers = computed( + () => auth.currentUser.value?.fiat_providers ?? [] + ) + const hasAnyProvider = computed(() => providers.value.length > 0) + + async function refresh(): Promise { + await auth.refresh() + } + + return { providers, hasAnyProvider, refresh, providerMeta } +} diff --git a/src/modules/base/composables/usePriceConversion.ts b/src/modules/base/composables/usePriceConversion.ts new file mode 100644 index 0000000..5dfc083 --- /dev/null +++ b/src/modules/base/composables/usePriceConversion.ts @@ -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() +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(SERVICE_TOKENS.LNBITS_API) + + async function convert( + amount: number, + from: string, + to: string, + ): Promise { + 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).amount ?? + (data as Record).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, + from: Ref, + to: Ref, + debounceMs = 300, + ): { result: Ref; loading: Ref } { + const result = ref(null) + const loading = ref(false) + let activeToken = 0 + let timer: ReturnType | 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 } +}