From 0bced11623adbe5fac42e673d9333f5bb9cfb4ad Mon Sep 17 00:00:00 2001 From: padreug Date: Fri, 5 Sep 2025 15:17:51 +0200 Subject: [PATCH] Add PaymentService integration to enhance ticket purchasing and lightning payment functionality - Introduce PAYMENT_SERVICE token in di-container for dependency injection. - Update base module to register and initialize PaymentService, ensuring it is available for use. - Refactor useTicketPurchase and useLightningPayment composables to utilize PaymentService for wallet management, payment processing, and QR code generation. - Delegate payment handling and error management to PaymentService, streamlining the payment workflow and improving user experience. --- src/core/di-container.ts | 3 + src/core/services/PaymentService.ts | 280 ++++++++++++++++++ src/modules/base/index.ts | 11 + .../events/composables/useTicketPurchase.ts | 57 ++-- .../market/composables/useLightningPayment.ts | 158 ++++------ 5 files changed, 372 insertions(+), 137 deletions(-) create mode 100644 src/core/services/PaymentService.ts diff --git a/src/core/di-container.ts b/src/core/di-container.ts index ff5a625..3da625e 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -114,6 +114,9 @@ export const SERVICE_TOKENS = { // Auth services AUTH_SERVICE: Symbol('authService'), + // Payment services + PAYMENT_SERVICE: Symbol('paymentService'), + // Market services MARKET_STORE: Symbol('marketStore'), PAYMENT_MONITOR: Symbol('paymentMonitor'), diff --git a/src/core/services/PaymentService.ts b/src/core/services/PaymentService.ts new file mode 100644 index 0000000..a3a0227 --- /dev/null +++ b/src/core/services/PaymentService.ts @@ -0,0 +1,280 @@ +import { ref, computed } from 'vue' +import { BaseService } from '@/core/base/BaseService' +import { payInvoiceWithWallet } from '@/lib/api/events' +import { toast } from 'vue-sonner' + +export interface PaymentResult { + payment_hash: string + fee_msat: number + preimage: string +} + +export interface QRCodeOptions { + width?: number + margin?: number + darkColor?: string + lightColor?: string +} + +export interface PaymentOptions { + successMessage?: string + errorMessage?: string + showToast?: boolean +} + +/** + * Centralized Payment Service + * Handles Lightning payments, QR code generation, and wallet management + */ +export class PaymentService extends BaseService { + // Service metadata + protected readonly metadata = { + name: 'PaymentService', + version: '1.0.0', + dependencies: ['AuthService'] + } + + // Payment state + private _isProcessingPayment = ref(false) + private _paymentError = ref(null) + + // Public reactive state + public readonly isProcessingPayment = computed(() => this._isProcessingPayment.value) + public readonly paymentError = computed(() => this._paymentError.value) + + /** + * Service-specific initialization (called by BaseService) + */ + protected async onInitialize(): Promise { + this.debug('PaymentService initialized') + } + + /** + * Get user wallets from authenticated user + */ + get userWallets() { + return this.authService?.user?.value?.wallets || [] + } + + /** + * Check if user has any wallet with balance + */ + get hasWalletWithBalance(): boolean { + return this.userWallets.some((wallet: any) => wallet.balance_msat > 0) + } + + /** + * Find wallet with sufficient balance for payment + */ + getWalletWithBalance(requiredAmountSats?: number): any | null { + const wallets = this.userWallets + if (!wallets.length) return null + + if (requiredAmountSats) { + // Convert sats to msat for comparison + const requiredMsat = requiredAmountSats * 1000 + return wallets.find((wallet: any) => wallet.balance_msat >= requiredMsat) + } + + return wallets.find((wallet: any) => wallet.balance_msat > 0) + } + + /** + * Generate QR code for Lightning payment request + */ + async generateQRCode( + paymentRequest: string, + options: QRCodeOptions = {} + ): Promise { + try { + const qrcode = await import('qrcode') + const dataUrl = await qrcode.toDataURL(paymentRequest, { + width: options.width || 256, + margin: options.margin || 2, + color: { + dark: options.darkColor || '#000000', + light: options.lightColor || '#FFFFFF' + } + }) + return dataUrl + } catch (error) { + const err = this.handleError(error, 'generateQRCode') + throw err + } + } + + /** + * Generate QR code for ticket or other custom data + */ + async generateCustomQRCode( + data: string, + options: QRCodeOptions = {} + ): Promise { + try { + const qrcode = await import('qrcode') + const dataUrl = await qrcode.toDataURL(data, { + width: options.width || 128, + margin: options.margin || 2, + color: { + dark: options.darkColor || '#000000', + light: options.lightColor || '#FFFFFF' + } + }) + return dataUrl + } catch (error) { + const err = this.handleError(error, 'generateCustomQRCode') + throw err + } + } + + /** + * Pay Lightning invoice with user's wallet + */ + async payInvoiceWithUserWallet( + paymentRequest: string, + requiredAmountSats?: number, + options: PaymentOptions = {} + ): Promise { + // Check authentication + if (!this.authService?.isAuthenticated?.value || !this.authService?.user?.value) { + throw new Error('User must be authenticated to pay with wallet') + } + + // Find suitable wallet + const wallet = this.getWalletWithBalance(requiredAmountSats) + if (!wallet) { + throw new Error('No wallet with sufficient balance found') + } + + try { + this._isProcessingPayment.value = true + this._paymentError.value = null + + this.debug(`Paying invoice with wallet: ${wallet.id.slice(0, 8)}`) + + // Make payment + const paymentResult = await payInvoiceWithWallet( + paymentRequest, + wallet.id, + wallet.adminkey + ) + + this.debug('Payment successful', { + paymentHash: paymentResult.payment_hash, + feeMsat: paymentResult.fee_msat + }) + + // Show success notification if enabled + if (options.showToast !== false) { + const message = options.successMessage || 'Payment successful!' + toast.success(message) + } + + return paymentResult + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Payment failed' + this._paymentError.value = errorMessage + + this.debug('Payment failed', error) + + // Show error notification if enabled + if (options.showToast !== false) { + const message = options.errorMessage || errorMessage + toast.error('Payment failed', { + description: message + }) + } + + throw error + } finally { + this._isProcessingPayment.value = false + } + } + + /** + * Open external Lightning wallet + */ + openExternalLightningWallet(paymentRequest: string): void { + if (!paymentRequest) { + toast.error('No payment request available') + return + } + + try { + const lightningUrl = `lightning:${paymentRequest}` + window.open(lightningUrl, '_blank') + + toast.info('Opening Lightning wallet...', { + description: 'If no wallet opens, copy the payment request manually' + }) + } catch (error) { + console.warn('Failed to open lightning: URL:', error) + + // Fallback: copy to clipboard + navigator.clipboard?.writeText(paymentRequest).then(() => { + toast.success('Payment request copied to clipboard') + }).catch(() => { + toast.info('Please copy the payment request manually') + }) + } + } + + /** + * Handle payment with automatic fallback + * Tries wallet payment first, falls back to external wallet + */ + async handlePaymentWithFallback( + paymentRequest: string, + requiredAmountSats?: number, + options: PaymentOptions = {} + ): Promise { + if (!paymentRequest) { + toast.error('No payment request available') + return null + } + + // Try wallet payment first if user has balance + if (this.hasWalletWithBalance) { + try { + return await this.payInvoiceWithUserWallet( + paymentRequest, + requiredAmountSats, + options + ) + } catch (error) { + this.debug('Wallet payment failed, offering external wallet option:', error) + // Don't throw here, continue to external wallet option + } + } + + // Fallback to external wallet + this.openExternalLightningWallet(paymentRequest) + return null // External wallet payment status unknown + } + + /** + * Clear payment error state + */ + clearPaymentError(): void { + this._paymentError.value = null + } + + /** + * Reset payment state + */ + resetPaymentState(): void { + this._isProcessingPayment.value = false + this._paymentError.value = null + } + + /** + * Cleanup when service is disposed (called by BaseService) + */ + protected async onDispose(): Promise { + this.resetPaymentState() + this.debug('PaymentService disposed') + } +} + +// Export singleton instance +export const paymentService = new PaymentService() \ No newline at end of file diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index ebaafd9..b3f7757 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -10,6 +10,9 @@ import { auth } from './auth/auth-service' // Import PWA services import { pwaService } from './pwa/pwa-service' +// Import payment service +import { paymentService } from '@/core/services/PaymentService' + /** * Base Module Plugin * Provides core infrastructure: Nostr, Auth, PWA, and UI components @@ -28,6 +31,9 @@ export const baseModule: ModulePlugin = { // Register auth service container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth) + // Register payment service + container.provide(SERVICE_TOKENS.PAYMENT_SERVICE, paymentService) + // Register PWA service container.provide('pwaService', pwaService) @@ -37,6 +43,10 @@ export const baseModule: ModulePlugin = { waitForDependencies: false, // Auth has no dependencies maxRetries: 1 }) + await paymentService.initialize({ + waitForDependencies: true, // PaymentService depends on AuthService + maxRetries: 3 + }) console.log('✅ Base module installed successfully') }, @@ -55,6 +65,7 @@ export const baseModule: ModulePlugin = { relayHub, nostrclientHub, auth, + paymentService, pwaService }, diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index 30cc322..64b304d 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -1,11 +1,14 @@ import { ref, computed, onUnmounted } from 'vue' -import { purchaseTicket, checkPaymentStatus, payInvoiceWithWallet } from '@/lib/api/events' +import { purchaseTicket, checkPaymentStatus } from '@/lib/api/events' import { useAuth } from '@/composables/useAuth' import { toast } from 'vue-sonner' import { useAsyncOperation } from '@/core/composables/useAsyncOperation' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { PaymentService } from '@/core/services/PaymentService' export function useTicketPurchase() { const { isAuthenticated, currentUser } = useAuth() + const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService // Async operations const purchaseOperation = useAsyncOperation() @@ -15,7 +18,6 @@ export function useTicketPurchase() { const paymentRequest = ref(null) const qrCode = ref(null) const isPaymentPending = ref(false) - const isPayingWithWallet = ref(false) // Ticket QR code state const ticketQRCode = ref(null) @@ -32,42 +34,28 @@ export function useTicketPurchase() { } }) - const userWallets = computed(() => currentUser.value?.wallets || []) - const hasWalletWithBalance = computed(() => - userWallets.value.some((wallet: any) => wallet.balance_msat > 0) - ) + // Delegate to PaymentService + const userWallets = computed(() => paymentService.userWallets) + const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance) + const isPayingWithWallet = computed(() => paymentService.isProcessingPayment) - // Generate QR code for Lightning payment + // Generate QR code for Lightning payment - delegate to PaymentService async function generateQRCode(bolt11: string) { try { - const qrcode = await import('qrcode') - const dataUrl = await qrcode.toDataURL(bolt11, { - width: 256, - margin: 2, - color: { - dark: '#000000', - light: '#FFFFFF' - } - }) - qrCode.value = dataUrl + qrCode.value = await paymentService.generateQRCode(bolt11) } catch (err) { console.error('Error generating QR code:', err) // Note: error handling is now managed by the purchaseOperation } } - // Generate QR code for ticket + // Generate QR code for ticket - delegate to PaymentService async function generateTicketQRCode(ticketId: string) { try { - const qrcode = await import('qrcode') const ticketUrl = `ticket://${ticketId}` - const dataUrl = await qrcode.toDataURL(ticketUrl, { + const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, { width: 128, - margin: 2, - color: { - dark: '#000000', - light: '#FFFFFF' - } + margin: 2 }) ticketQRCode.value = dataUrl return dataUrl @@ -77,16 +65,12 @@ export function useTicketPurchase() { } } - // Pay with wallet + // Pay with wallet - delegate to PaymentService async function payWithWallet(paymentRequest: string) { - const walletWithBalance = userWallets.value.find((wallet: any) => wallet.balance_msat > 0) - - if (!walletWithBalance) { - throw new Error('No wallet with sufficient balance found') - } - try { - await payInvoiceWithWallet(paymentRequest, walletWithBalance.id, walletWithBalance.adminkey) + await paymentService.payInvoiceWithUserWallet(paymentRequest, undefined, { + showToast: false // We'll handle success notifications in the ticket purchase flow + }) return true } catch (error) { console.error('Wallet payment failed:', error) @@ -119,7 +103,6 @@ export function useTicketPurchase() { // Try to pay with wallet if available if (hasWalletWithBalance.value) { - isPayingWithWallet.value = true try { await payWithWallet(invoice.payment_request) // If wallet payment succeeds, proceed to check payment status @@ -127,7 +110,6 @@ export function useTicketPurchase() { } catch (walletError) { // If wallet payment fails, fall back to manual payment console.log('Wallet payment failed, falling back to manual payment:', walletError) - isPayingWithWallet.value = false await startPaymentStatusCheck(eventId, invoice.payment_hash) } } else { @@ -189,16 +171,15 @@ export function useTicketPurchase() { paymentRequest.value = null qrCode.value = null isPaymentPending.value = false - isPayingWithWallet.value = false ticketQRCode.value = null purchasedTicketId.value = null showTicketQR.value = false } - // Open Lightning wallet + // Open Lightning wallet - delegate to PaymentService function handleOpenLightningWallet() { if (paymentRequest.value) { - window.open(`lightning:${paymentRequest.value}`, '_blank') + paymentService.openExternalLightningWallet(paymentRequest.value) } } diff --git a/src/modules/market/composables/useLightningPayment.ts b/src/modules/market/composables/useLightningPayment.ts index bc9ae69..5109f0c 100644 --- a/src/modules/market/composables/useLightningPayment.ts +++ b/src/modules/market/composables/useLightningPayment.ts @@ -1,60 +1,36 @@ -import { ref, computed } from 'vue' -import { useAuth } from '@/composables/useAuth' +import { computed } from 'vue' import { useMarketStore } from '../stores/market' -import { payInvoiceWithWallet } from '@/lib/api/events' -import { toast } from 'vue-sonner' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { PaymentService } from '@/core/services/PaymentService' export function useLightningPayment() { - const { isAuthenticated, currentUser } = useAuth() + const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const marketStore = useMarketStore() - - // State - const isPayingWithWallet = ref(false) - const paymentError = ref(null) - // Computed properties - const userWallets = computed(() => currentUser.value?.wallets || []) - const hasWalletWithBalance = computed(() => - userWallets.value.some((wallet: any) => wallet.balance_msat > 0) - ) + // Computed properties - delegate to PaymentService + const userWallets = computed(() => paymentService.userWallets) + const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance) + const isPayingWithWallet = computed(() => paymentService.isProcessingPayment) + const paymentError = computed(() => paymentService.paymentError) - // Get wallet with sufficient balance - const getWalletWithBalance = (requiredAmount?: number) => { - const wallets = userWallets.value - if (!wallets.length) return null - - if (requiredAmount) { - return wallets.find((wallet: any) => wallet.balance_msat >= requiredAmount * 1000) // Convert sats to msat - } - - return wallets.find((wallet: any) => wallet.balance_msat > 0) + // Get wallet with sufficient balance - delegate to PaymentService + const getWalletWithBalance = (requiredAmountSats?: number) => { + return paymentService.getWalletWithBalance(requiredAmountSats) } // Pay Lightning invoice with user's wallet async function payInvoice(paymentRequest: string, orderId?: string): Promise { - if (!isAuthenticated.value || !currentUser.value) { - throw new Error('User must be authenticated to pay with wallet') - } - - const wallet = getWalletWithBalance() - if (!wallet) { - throw new Error('No wallet with sufficient balance found') - } - try { - isPayingWithWallet.value = true - paymentError.value = null - - console.log('💰 Paying invoice with wallet:', wallet.id.slice(0, 8)) - - // Use the same API function as events - const paymentResult = await payInvoiceWithWallet(paymentRequest, wallet.id, wallet.adminkey) - - console.log('✅ Payment successful:', { - paymentHash: paymentResult.payment_hash, - feeMsat: paymentResult.fee_msat, - orderId - }) + // Use PaymentService with custom success message + const paymentResult = await paymentService.payInvoiceWithUserWallet( + paymentRequest, + undefined, // No specific amount requirement + { + successMessage: orderId + ? `Order ${orderId.slice(-8)} has been paid` + : 'Lightning invoice paid successfully' + } + ) // Update order status to paid if orderId is provided if (orderId) { @@ -77,72 +53,56 @@ export function useLightningPayment() { } } - toast.success('Payment successful!', { - description: orderId ? `Order ${orderId.slice(-8)} has been paid` : 'Lightning invoice paid successfully' - }) - return true } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Payment failed' - paymentError.value = errorMessage - console.error('💸 Payment failed:', error) - - toast.error('Payment failed', { - description: errorMessage - }) - - throw error - } finally { - isPayingWithWallet.value = false + throw error // PaymentService already handled error notifications } } - // Open external Lightning wallet (fallback) + // Open external Lightning wallet (fallback) - delegate to PaymentService function openExternalLightningWallet(paymentRequest: string) { - if (!paymentRequest) { - toast.error('No payment request available') - return - } - - // Try lightning: protocol first - const lightningUrl = `lightning:${paymentRequest}` - - try { - window.open(lightningUrl, '_blank') - toast.info('Opening Lightning wallet...', { - description: 'If no wallet opens, copy the payment request manually' - }) - } catch (error) { - console.warn('Failed to open lightning: URL, showing payment request for copy:', error) - // Fallback: copy to clipboard or show for manual copy - navigator.clipboard?.writeText(paymentRequest).then(() => { - toast.success('Payment request copied to clipboard') - }).catch(() => { - toast.info('Please copy the payment request manually') - }) - } + paymentService.openExternalLightningWallet(paymentRequest) } // Main payment handler - tries wallet first, falls back to external async function handlePayment(paymentRequest: string, orderId?: string): Promise { - if (!paymentRequest) { - toast.error('No payment request available') - return - } + try { + // Use PaymentService's automatic fallback handling + const result = await paymentService.handlePaymentWithFallback( + paymentRequest, + undefined, // No specific amount requirement + { + successMessage: orderId + ? `Order ${orderId.slice(-8)} has been paid` + : 'Lightning invoice paid successfully' + } + ) - // Try wallet payment first if user has balance - if (hasWalletWithBalance.value) { - try { - await payInvoice(paymentRequest, orderId) - return // Payment successful with wallet - } catch (error) { - console.log('Wallet payment failed, offering external wallet option:', error) - // Don't throw here, continue to external wallet option + // If payment was successful with wallet, update order + if (result && orderId) { + const order = marketStore.orders[orderId] + if (order) { + const updatedOrder = { + ...order, + paymentStatus: 'paid' as const, + status: 'paid' as const, + paidAt: Math.floor(Date.now() / 1000), + paymentHash: result.payment_hash, + feeMsat: result.fee_msat, + items: [...order.items], // Convert readonly to mutable + shippingZone: order.shippingZone ? { + ...order.shippingZone, + countries: order.shippingZone.countries ? [...order.shippingZone.countries] : undefined + } : order.shippingZone + } + marketStore.updateOrder(orderId, updatedOrder) + } } + // If result is null, external wallet was opened (fallback case) + } catch (error) { + // PaymentService already handled error notifications + throw error } - - // Fallback to external wallet - openExternalLightningWallet(paymentRequest) } return {