- Introduced computed properties in ReceiveDialog.vue to dynamically display payment status, including color coding and status text based on transaction updates. - Updated WalletService.ts to improve payment status mapping, handling various payment states more explicitly and ensuring accurate timestamp parsing. - Adjusted the transaction display logic to convert amounts from millisats to sats for better clarity. These changes improve the user experience by providing real-time feedback on payment statuses and ensuring accurate transaction information.
528 lines
16 KiB
TypeScript
528 lines
16 KiB
TypeScript
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<PayLink[]>([])
|
|
private _transactions = ref<PaymentTransaction[]>([])
|
|
private _isCreatingPayLink = ref(false)
|
|
private _isSendingPayment = ref(false)
|
|
private _isCreatingInvoice = ref(false)
|
|
private _error = ref<string | null>(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<void> {
|
|
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<PayLink | null> {
|
|
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<Invoice | null> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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
|
|
}
|
|
}
|
|
}
|