import { ref, computed } from 'vue' import { BaseService } from '@/core/base/BaseService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { config } from '@/lib/config' export interface PayLink { id: string wallet: string description: string min: number max: number comment_chars: number username?: string lnurl?: string lnaddress?: string } export interface SendPaymentRequest { amount: number destination: string // Can be invoice, lnurl, or lightning address comment?: string } export interface CreateInvoiceRequest { amount: number memo: string expiry?: number // Optional expiry in seconds } export interface Invoice { payment_hash: string bolt11: string // The BOLT11 invoice payment_request: string // Same as bolt11, for compatibility checking_id: string amount: number memo: string time: number expiry: number | null } export interface PaymentTransaction { id: string amount: number description: string timestamp: Date type: 'sent' | 'received' status: 'pending' | 'confirmed' | 'failed' fee?: number tag?: string | null } export default class WalletService extends BaseService { private paymentService: any = null // Required metadata for BaseService protected readonly metadata = { name: 'WalletService', version: '1.0.0', dependencies: [] // No specific dependencies } // Reactive state private _payLinks = ref([]) private _transactions = ref([]) private _isCreatingPayLink = ref(false) private _isSendingPayment = ref(false) private _isCreatingInvoice = ref(false) private _error = ref(null) // Public reactive getters readonly payLinks = computed(() => this._payLinks.value) readonly transactions = computed(() => this._transactions.value) readonly isCreatingPayLink = computed(() => this._isCreatingPayLink.value) readonly isSendingPayment = computed(() => this._isSendingPayment.value) readonly isCreatingInvoice = computed(() => this._isCreatingInvoice.value) readonly error = computed(() => this._error.value) protected async onInitialize(): Promise { try { this.paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) if (!this.paymentService) { throw new Error('Payment service not available') } // Load initial data await this.loadPayLinks() await this.loadTransactions() console.log('WalletService initialized successfully') } catch (error) { console.error('Failed to initialize WalletService:', error) this._error.value = error instanceof Error ? error.message : 'Initialization failed' throw error } } /** * Create a new LNURL pay link for receiving payments */ async createReceiveAddress(params: { description: string minAmount: number maxAmount: number username?: string allowComments?: boolean }): Promise { this._isCreatingPayLink.value = true this._error.value = null try { const wallet = this.paymentService?.getPreferredWallet() if (!wallet) { throw new Error('No wallet available') } const adminKey = this.paymentService?.getPreferredWalletAdminKey() if (!adminKey) { throw new Error('No admin key available') } // Create pay link via LNbits LNURLP extension API const response = await fetch(`${config.api.baseUrl}/lnurlp/api/v1/links`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': adminKey }, body: JSON.stringify({ description: params.description, wallet: wallet.id, min: params.minAmount, max: params.maxAmount, comment_chars: params.allowComments ? 200 : 0, username: params.username || null, disposable: false // Reusable pay link }) }) if (!response.ok) { const error = await response.json() throw new Error(error.detail || 'Failed to create pay link') } const payLink: PayLink = await response.json() // Generate the LNURL and Lightning Address const baseUrl = config.api.baseUrl payLink.lnurl = `${baseUrl}/lnurlp/${payLink.id}` if (payLink.username) { // Extract domain from base URL const domain = new URL(baseUrl).hostname payLink.lnaddress = `${payLink.username}@${domain}` } this._payLinks.value.unshift(payLink) console.log('Created new pay link:', payLink.id) return payLink } catch (error) { console.error('Failed to create receive address:', error) this._error.value = error instanceof Error ? error.message : 'Failed to create receive address' return null } finally { this._isCreatingPayLink.value = false } } /** * Create a Lightning invoice for receiving payments */ async createInvoice(request: CreateInvoiceRequest): Promise { this._isCreatingInvoice.value = true this._error.value = null try { const invoiceKey = this.paymentService?.getPreferredWalletInvoiceKey() if (!invoiceKey) { throw new Error('No invoice key available') } // Create invoice via LNbits payments API const response = await fetch(`${config.api.baseUrl}/api/v1/payments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': invoiceKey }, body: JSON.stringify({ out: false, // Incoming payment (receiving) amount: request.amount, unit: 'sat', memo: request.memo, expiry: request.expiry || 3600 // Default 1 hour expiry }) }) if (!response.ok) { const error = await response.json() throw new Error(error.detail || 'Failed to create invoice') } const rawInvoice = await response.json() console.log('Raw invoice response:', rawInvoice) // Process the response to fix data issues const invoice: Invoice = { ...rawInvoice, payment_request: rawInvoice.bolt11, // Copy bolt11 to payment_request for compatibility amount: rawInvoice.amount / 1000, // Convert from millisats to sats expiry: rawInvoice.expiry ? this.parseExpiryToSeconds(rawInvoice.expiry) : null } console.log('Processed invoice:', invoice) return invoice } catch (error) { console.error('Failed to create invoice:', error) this._error.value = error instanceof Error ? error.message : 'Failed to create invoice' return null } finally { this._isCreatingInvoice.value = false } } /** * Send a Lightning payment */ async sendPayment(request: SendPaymentRequest): Promise { this._isSendingPayment.value = true this._error.value = null try { const adminKey = this.paymentService?.getPreferredWalletAdminKey() if (!adminKey) { throw new Error('No admin key available') } let endpoint = '' let body: any = {} // Determine payment type and prepare request if (request.destination.startsWith('ln')) { // Lightning invoice endpoint = `${config.api.baseUrl}/api/v1/payments` body = { out: true, bolt11: request.destination } } else if (request.destination.includes('@') || request.destination.toLowerCase().startsWith('lnurl')) { // Lightning address or LNURL endpoint = `${config.api.baseUrl}/api/v1/payments/lnurl` body = { lnurl: request.destination.includes('@') ? `https://${request.destination.split('@')[1]}/.well-known/lnurlp/${request.destination.split('@')[0]}` : request.destination, amount: request.amount * 1000, // Convert to millisats comment: request.comment || '' } } else { throw new Error('Invalid payment destination format') } const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': adminKey }, body: JSON.stringify(body) }) if (!response.ok) { const error = await response.json() throw new Error(error.detail || 'Payment failed') } const payment = await response.json() console.log('Payment sent successfully:', payment.payment_hash) // Refresh transactions await this.loadTransactions() return true } catch (error) { console.error('Failed to send payment:', error) this._error.value = error instanceof Error ? error.message : 'Payment failed' return false } finally { this._isSendingPayment.value = false } } /** * Load existing pay links */ private async loadPayLinks(): Promise { try { const adminKey = this.paymentService?.getPreferredWalletAdminKey() if (!adminKey) return const response = await fetch(`${config.api.baseUrl}/lnurlp/api/v1/links`, { headers: { 'X-Api-Key': adminKey } }) if (response.ok) { const links = await response.json() const baseUrl = config.api.baseUrl const domain = new URL(baseUrl).hostname // Add LNURL and Lightning Address to each link this._payLinks.value = links.map((link: PayLink) => ({ ...link, lnurl: `${baseUrl}/lnurlp/${link.id}`, lnaddress: link.username ? `${link.username}@${domain}` : undefined })) console.log(`Loaded ${links.length} pay links`) } } catch (error) { console.error('Failed to load pay links:', error) } } /** * Load payment transactions */ private async loadTransactions(): Promise { try { const invoiceKey = this.paymentService?.getPreferredWalletInvoiceKey() if (!invoiceKey) return const response = await fetch(`${config.api.baseUrl}/api/v1/payments`, { headers: { 'X-Api-Key': invoiceKey } }) if (response.ok) { const payments = await response.json() // Transform to our transaction format this._transactions.value = payments.map((payment: any) => { let timestamp = new Date() if (payment.time) { // Check if it's an ISO string or Unix timestamp if (typeof payment.time === 'string' && payment.time.includes('T')) { // ISO string format (e.g., "2025-09-14T16:49:40.378877+00:00") timestamp = new Date(payment.time) } else if (typeof payment.time === 'number' || !isNaN(Number(payment.time))) { // Unix timestamp (seconds) - multiply by 1000 for milliseconds timestamp = new Date(Number(payment.time) * 1000) } else { // Try to parse as-is timestamp = new Date(payment.time) } } return { id: payment.payment_hash, amount: Math.abs(payment.amount) / 1000, description: payment.memo || payment.description || 'No description', timestamp: timestamp, type: payment.amount > 0 ? 'received' : 'sent', status: payment.pending ? 'pending' : 'confirmed', fee: payment.fee ? payment.fee / 1000 : undefined, tag: payment.tag || (payment.extra && payment.extra.tag) || null } }).sort((a: PaymentTransaction, b: PaymentTransaction) => b.timestamp.getTime() - a.timestamp.getTime() ) console.log(`Loaded ${payments.length} transactions`) } } catch (error) { console.error('Failed to load transactions:', error) } } /** * Delete a pay link */ async deletePayLink(linkId: string): Promise { try { const adminKey = this.paymentService?.getPreferredWalletAdminKey() if (!adminKey) { throw new Error('No admin key available') } const response = await fetch(`${config.api.baseUrl}/lnurlp/api/v1/links/${linkId}`, { method: 'DELETE', headers: { 'X-Api-Key': adminKey } }) if (!response.ok) { throw new Error('Failed to delete pay link') } // Remove from local state this._payLinks.value = this._payLinks.value.filter(link => link.id !== linkId) console.log('Deleted pay link:', linkId) return true } catch (error) { console.error('Failed to delete pay link:', error) this._error.value = error instanceof Error ? error.message : 'Failed to delete pay link' return false } } /** * Refresh all data */ async refresh(): Promise { await Promise.all([ this.loadPayLinks(), this.loadTransactions() ]) } /** * Parse expiry date string to seconds from now */ private parseExpiryToSeconds(expiryStr: string): number { try { const expiryDate = new Date(expiryStr) const now = new Date() const diffMs = expiryDate.getTime() - now.getTime() return Math.max(0, Math.floor(diffMs / 1000)) // Return seconds, minimum 0 } catch (error) { console.error('Failed to parse expiry date:', expiryStr, error) return 3600 // Default to 1 hour } } /** * Add a new transaction from WebSocket notification */ addTransaction(payment: any): void { try { const transaction = this.mapPaymentToTransaction(payment) // Check if transaction already exists (avoid duplicates) const existingIndex = this._transactions.value.findIndex(t => t.id === transaction.id) if (existingIndex >= 0) { // Update existing transaction this._transactions.value[existingIndex] = transaction } else { // Add new transaction at the beginning (most recent first) this._transactions.value = [transaction, ...this._transactions.value] } console.log('WalletService: Added/updated transaction', transaction) } catch (error) { console.error('WalletService: Failed to add transaction', error) } } /** * Map LNbits payment object to our transaction format */ private mapPaymentToTransaction(payment: any): PaymentTransaction { // Handle timestamp parsing - try different formats let timestamp = new Date() if (payment.time) { if (typeof payment.time === 'string') { // ISO string format timestamp = new Date(payment.time) } else if (typeof payment.time === 'number') { // Unix timestamp (seconds) timestamp = new Date(payment.time * 1000) } } // For the transaction display, convert amount from millisats to sats const amountSats = Math.abs(payment.amount) / 1000 // Map status correctly - be more explicit about the mapping let status: 'pending' | 'confirmed' | 'failed' = 'pending' // Check for pending first if (payment.pending === true) { status = 'pending' } // Check for success status else if (payment.status === 'success' || payment.status === 'settled' || payment.status === 'confirmed') { status = 'confirmed' } // Check for failed status else if (payment.status === 'failed') { status = 'failed' } // If status is success but no pending field, assume confirmed else if (payment.status === 'success' && payment.pending !== true) { status = 'confirmed' } console.log('WalletService: Mapping payment', { originalAmount: payment.amount, convertedAmount: amountSats, originalStatus: payment.status, pending: payment.pending, mappedStatus: status, timestamp: timestamp }) return { id: payment.payment_hash || payment.checking_id || payment.id, amount: amountSats, description: payment.description || payment.memo || 'Payment', timestamp: timestamp, type: payment.amount > 0 ? 'received' : 'sent', status: status, fee: payment.fee_msat ? payment.fee_msat / 1000 : undefined, tag: payment.tag || null } } }