diff --git a/CLAUDE.md b/CLAUDE.md index b85fab5..2ef2826 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -714,90 +714,6 @@ 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 1e1ecc9..c837861 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -40,8 +40,7 @@ interface User { username?: string email?: string pubkey?: string - // pragma: allowlist secret - prvkey?: string // Nostr signing key for user + prvkey?: string // Nostr private key for user external_id?: string extensions: string[] wallets: Wallet[] @@ -192,13 +191,6 @@ 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 739e300..7f39fc0 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -32,13 +32,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next' +import { Calendar, Loader2, MapPin, AlertCircle } 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' @@ -121,35 +120,20 @@ 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) { - 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) { + 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) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ['fiat_currency'], - message: 'Pick a fiat currency for buyers paying by card', + path: ['event_end_date'], + message: 'End must be on or after start', }) } }) @@ -166,8 +150,6 @@ const form = useForm({ event_end_time: '', location: '', currency: 'sat', - allow_fiat: false, - fiat_currency: 'USD', amount_tickets: 0, price_per_ticket: 0, } @@ -231,8 +213,6 @@ 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, }) @@ -338,13 +318,6 @@ 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 @@ -574,79 +547,48 @@ const handleOpenChange = (open: boolean) => { /> - -
-
-

Pricing

-

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

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

Payment methods

-

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

-
-
- - Lightning — always on -
- + + + Currency + + + + + +
diff --git a/src/modules/activities/components/PurchaseTicketDialog.vue b/src/modules/activities/components/PurchaseTicketDialog.vue index 22eb1b8..643e8e0 100644 --- a/src/modules/activities/components/PurchaseTicketDialog.vue +++ b/src/modules/activities/components/PurchaseTicketDialog.vue @@ -1,20 +1,12 @@ - - diff --git a/src/modules/base/components/payments/PaymentMethodSelector.vue b/src/modules/base/components/payments/PaymentMethodSelector.vue deleted file mode 100644 index a5fb3dd..0000000 --- a/src/modules/base/components/payments/PaymentMethodSelector.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/src/modules/base/components/payments/PriceConversionPreview.vue b/src/modules/base/components/payments/PriceConversionPreview.vue deleted file mode 100644 index 17cdba3..0000000 --- a/src/modules/base/components/payments/PriceConversionPreview.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - diff --git a/src/modules/base/composables/useFiatProviders.ts b/src/modules/base/composables/useFiatProviders.ts deleted file mode 100644 index ac28691..0000000 --- a/src/modules/base/composables/useFiatProviders.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 5dfc083..0000000 --- a/src/modules/base/composables/usePriceConversion.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 } -}