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.
This commit is contained in:
padreug 2025-09-05 15:17:51 +02:00
parent adf32c0dca
commit 0bced11623
5 changed files with 372 additions and 137 deletions

View file

@ -114,6 +114,9 @@ export const SERVICE_TOKENS = {
// Auth services // Auth services
AUTH_SERVICE: Symbol('authService'), AUTH_SERVICE: Symbol('authService'),
// Payment services
PAYMENT_SERVICE: Symbol('paymentService'),
// Market services // Market services
MARKET_STORE: Symbol('marketStore'), MARKET_STORE: Symbol('marketStore'),
PAYMENT_MONITOR: Symbol('paymentMonitor'), PAYMENT_MONITOR: Symbol('paymentMonitor'),

View file

@ -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<string | null>(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<void> {
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<string> {
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<string> {
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<PaymentResult> {
// 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<PaymentResult | null> {
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<void> {
this.resetPaymentState()
this.debug('PaymentService disposed')
}
}
// Export singleton instance
export const paymentService = new PaymentService()

View file

@ -10,6 +10,9 @@ import { auth } from './auth/auth-service'
// Import PWA services // Import PWA services
import { pwaService } from './pwa/pwa-service' import { pwaService } from './pwa/pwa-service'
// Import payment service
import { paymentService } from '@/core/services/PaymentService'
/** /**
* Base Module Plugin * Base Module Plugin
* Provides core infrastructure: Nostr, Auth, PWA, and UI components * Provides core infrastructure: Nostr, Auth, PWA, and UI components
@ -28,6 +31,9 @@ export const baseModule: ModulePlugin = {
// Register auth service // Register auth service
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth) container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
// Register payment service
container.provide(SERVICE_TOKENS.PAYMENT_SERVICE, paymentService)
// Register PWA service // Register PWA service
container.provide('pwaService', pwaService) container.provide('pwaService', pwaService)
@ -37,6 +43,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: false, // Auth has no dependencies waitForDependencies: false, // Auth has no dependencies
maxRetries: 1 maxRetries: 1
}) })
await paymentService.initialize({
waitForDependencies: true, // PaymentService depends on AuthService
maxRetries: 3
})
console.log('✅ Base module installed successfully') console.log('✅ Base module installed successfully')
}, },
@ -55,6 +65,7 @@ export const baseModule: ModulePlugin = {
relayHub, relayHub,
nostrclientHub, nostrclientHub,
auth, auth,
paymentService,
pwaService pwaService
}, },

View file

@ -1,11 +1,14 @@
import { ref, computed, onUnmounted } from 'vue' 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 { useAuth } from '@/composables/useAuth'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
// Async operations // Async operations
const purchaseOperation = useAsyncOperation() const purchaseOperation = useAsyncOperation()
@ -15,7 +18,6 @@ export function useTicketPurchase() {
const paymentRequest = ref<string | null>(null) const paymentRequest = ref<string | null>(null)
const qrCode = ref<string | null>(null) const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false) const isPaymentPending = ref(false)
const isPayingWithWallet = ref(false)
// Ticket QR code state // Ticket QR code state
const ticketQRCode = ref<string | null>(null) const ticketQRCode = ref<string | null>(null)
@ -32,42 +34,28 @@ export function useTicketPurchase() {
} }
}) })
const userWallets = computed(() => currentUser.value?.wallets || []) // Delegate to PaymentService
const hasWalletWithBalance = computed(() => const userWallets = computed(() => paymentService.userWallets)
userWallets.value.some((wallet: any) => wallet.balance_msat > 0) 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) { async function generateQRCode(bolt11: string) {
try { try {
const qrcode = await import('qrcode') qrCode.value = await paymentService.generateQRCode(bolt11)
const dataUrl = await qrcode.toDataURL(bolt11, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
qrCode.value = dataUrl
} catch (err) { } catch (err) {
console.error('Error generating QR code:', err) console.error('Error generating QR code:', err)
// Note: error handling is now managed by the purchaseOperation // 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) { async function generateTicketQRCode(ticketId: string) {
try { try {
const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}` const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, { const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, {
width: 128, width: 128,
margin: 2, margin: 2
color: {
dark: '#000000',
light: '#FFFFFF'
}
}) })
ticketQRCode.value = dataUrl ticketQRCode.value = dataUrl
return dataUrl return dataUrl
@ -77,16 +65,12 @@ export function useTicketPurchase() {
} }
} }
// Pay with wallet // Pay with wallet - delegate to PaymentService
async function payWithWallet(paymentRequest: string) { 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 { 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 return true
} catch (error) { } catch (error) {
console.error('Wallet payment failed:', error) console.error('Wallet payment failed:', error)
@ -119,7 +103,6 @@ export function useTicketPurchase() {
// Try to pay with wallet if available // Try to pay with wallet if available
if (hasWalletWithBalance.value) { if (hasWalletWithBalance.value) {
isPayingWithWallet.value = true
try { try {
await payWithWallet(invoice.payment_request) await payWithWallet(invoice.payment_request)
// If wallet payment succeeds, proceed to check payment status // If wallet payment succeeds, proceed to check payment status
@ -127,7 +110,6 @@ export function useTicketPurchase() {
} catch (walletError) { } catch (walletError) {
// If wallet payment fails, fall back to manual payment // If wallet payment fails, fall back to manual payment
console.log('Wallet payment failed, falling back to manual payment:', walletError) console.log('Wallet payment failed, falling back to manual payment:', walletError)
isPayingWithWallet.value = false
await startPaymentStatusCheck(eventId, invoice.payment_hash) await startPaymentStatusCheck(eventId, invoice.payment_hash)
} }
} else { } else {
@ -189,16 +171,15 @@ export function useTicketPurchase() {
paymentRequest.value = null paymentRequest.value = null
qrCode.value = null qrCode.value = null
isPaymentPending.value = false isPaymentPending.value = false
isPayingWithWallet.value = false
ticketQRCode.value = null ticketQRCode.value = null
purchasedTicketId.value = null purchasedTicketId.value = null
showTicketQR.value = false showTicketQR.value = false
} }
// Open Lightning wallet // Open Lightning wallet - delegate to PaymentService
function handleOpenLightningWallet() { function handleOpenLightningWallet() {
if (paymentRequest.value) { if (paymentRequest.value) {
window.open(`lightning:${paymentRequest.value}`, '_blank') paymentService.openExternalLightningWallet(paymentRequest.value)
} }
} }

View file

@ -1,60 +1,36 @@
import { ref, computed } from 'vue' import { computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { useMarketStore } from '../stores/market' import { useMarketStore } from '../stores/market'
import { payInvoiceWithWallet } from '@/lib/api/events' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { toast } from 'vue-sonner' import type { PaymentService } from '@/core/services/PaymentService'
export function useLightningPayment() { export function useLightningPayment() {
const { isAuthenticated, currentUser } = useAuth() const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
const marketStore = useMarketStore() const marketStore = useMarketStore()
// State // Computed properties - delegate to PaymentService
const isPayingWithWallet = ref(false) const userWallets = computed(() => paymentService.userWallets)
const paymentError = ref<string | null>(null) const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance)
const isPayingWithWallet = computed(() => paymentService.isProcessingPayment)
const paymentError = computed(() => paymentService.paymentError)
// Computed properties // Get wallet with sufficient balance - delegate to PaymentService
const userWallets = computed(() => currentUser.value?.wallets || []) const getWalletWithBalance = (requiredAmountSats?: number) => {
const hasWalletWithBalance = computed(() => return paymentService.getWalletWithBalance(requiredAmountSats)
userWallets.value.some((wallet: any) => wallet.balance_msat > 0)
)
// 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)
} }
// Pay Lightning invoice with user's wallet // Pay Lightning invoice with user's wallet
async function payInvoice(paymentRequest: string, orderId?: string): Promise<boolean> { async function payInvoice(paymentRequest: string, orderId?: string): Promise<boolean> {
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 { try {
isPayingWithWallet.value = true // Use PaymentService with custom success message
paymentError.value = null const paymentResult = await paymentService.payInvoiceWithUserWallet(
paymentRequest,
console.log('💰 Paying invoice with wallet:', wallet.id.slice(0, 8)) undefined, // No specific amount requirement
{
// Use the same API function as events successMessage: orderId
const paymentResult = await payInvoiceWithWallet(paymentRequest, wallet.id, wallet.adminkey) ? `Order ${orderId.slice(-8)} has been paid`
: 'Lightning invoice paid successfully'
console.log('✅ Payment successful:', { }
paymentHash: paymentResult.payment_hash, )
feeMsat: paymentResult.fee_msat,
orderId
})
// Update order status to paid if orderId is provided // Update order status to paid if orderId is provided
if (orderId) { 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 return true
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Payment failed' throw error // PaymentService already handled error notifications
paymentError.value = errorMessage
console.error('💸 Payment failed:', error)
toast.error('Payment failed', {
description: errorMessage
})
throw error
} finally {
isPayingWithWallet.value = false
} }
} }
// Open external Lightning wallet (fallback) // Open external Lightning wallet (fallback) - delegate to PaymentService
function openExternalLightningWallet(paymentRequest: string) { function openExternalLightningWallet(paymentRequest: string) {
if (!paymentRequest) { paymentService.openExternalLightningWallet(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')
})
}
} }
// Main payment handler - tries wallet first, falls back to external // Main payment handler - tries wallet first, falls back to external
async function handlePayment(paymentRequest: string, orderId?: string): Promise<void> { async function handlePayment(paymentRequest: string, orderId?: string): Promise<void> {
if (!paymentRequest) { try {
toast.error('No payment request available') // Use PaymentService's automatic fallback handling
return 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 payment was successful with wallet, update order
if (hasWalletWithBalance.value) { if (result && orderId) {
try { const order = marketStore.orders[orderId]
await payInvoice(paymentRequest, orderId) if (order) {
return // Payment successful with wallet const updatedOrder = {
} catch (error) { ...order,
console.log('Wallet payment failed, offering external wallet option:', error) paymentStatus: 'paid' as const,
// Don't throw here, continue to external wallet option 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 { return {