From 9c9a29f93a2e0759e9eca6d6d5b0f803753737db Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:40:38 +0200 Subject: [PATCH] fix(wallet): read payment status from `status`, not the unserialized `pending` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every payment in the history list rendered as "confirmed" — pending invoices looked settled and failed payments looked successful. The receive dialog's "Paid" indicator inherited the same defect. `Payment.pending` is a Python `@property` on the LNbits model, and LNbits pins pydantic 1.x, which never serializes properties. The field is therefore absent from every REST and WebSocket payload, so `payment.pending` was always `undefined` — falsy — and the ternary fell through to "confirmed" for every row. Verified against a live LNbits instance: the payload carries `status` ("pending" | "success" | "failed") and no `pending` key. A freshly created, unpaid invoice now maps to "pending" where it previously mapped to "confirmed". The same drift hid a second bug: the WebSocket mapper read `fee_msat`, which does not exist either. The field is `fee`, signed millisats like `amount`, so live-added rows never showed a fee. Both mappers existed as near-duplicates that had diverged, which is how the two fields fell out of sync in the first place. `loadTransactions` now delegates to the single shared mapper. --- src/modules/wallet/services/WalletService.ts | 118 +++++++++---------- 1 file changed, 54 insertions(+), 64 deletions(-) diff --git a/src/modules/wallet/services/WalletService.ts b/src/modules/wallet/services/WalletService.ts index 7607b99..cbfcd0b 100644 --- a/src/modules/wallet/services/WalletService.ts +++ b/src/modules/wallet/services/WalletService.ts @@ -352,37 +352,11 @@ export default class WalletService extends BaseService { 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() - ) + this._transactions.value = payments + .map((payment: any) => this.mapPaymentToTransaction(payment)) + .sort((a: PaymentTransaction, b: PaymentTransaction) => + b.timestamp.getTime() - a.timestamp.getTime() + ) console.log(`Loaded ${payments.length} transactions`) } @@ -478,51 +452,67 @@ export default class WalletService extends BaseService { * Map LNbits payment object to our transaction format */ private mapPaymentToTransaction(payment: any): PaymentTransaction { - // Handle timestamp parsing - try different formats + // `time` is serialized by pydantic as an ISO-8601 string + // ("2025-09-14T16:49:40.378877+00:00"). Older payloads and some + // extensions still hand back a unix-seconds number, so accept both. 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) + if (typeof payment.time === 'number') { timestamp = new Date(payment.time * 1000) + } else if (typeof payment.time === 'string' && !payment.time.includes('T')) { + // Numeric string = unix seconds + const asNumber = Number(payment.time) + timestamp = isNaN(asNumber) ? new Date(payment.time) : new Date(asNumber * 1000) + } else { + timestamp = new Date(payment.time) } } - // 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' - } + // `amount` is signed millisats: positive incoming, negative outgoing. + const amountMsat = Number(payment.amount ?? 0) + const amountSats = Math.abs(amountMsat) / 1000 + // `fee` is signed millisats as well. A zero fee is left undefined so the + // UI can omit the "Fee:" line entirely rather than printing "0 sats". + const feeMsat = Math.abs(Number(payment.fee ?? 0)) return { id: payment.payment_hash || payment.checking_id || payment.id, amount: amountSats, - description: payment.description || payment.memo || 'Payment', + description: payment.memo || payment.description || 'No description', timestamp: timestamp, - type: payment.amount > 0 ? 'received' : 'sent', - status: status, - fee: payment.fee_msat ? payment.fee_msat / 1000 : undefined, - tag: payment.tag || null + type: amountMsat > 0 ? 'received' : 'sent', + status: this.mapPaymentStatus(payment), + fee: feeMsat > 0 ? feeMsat / 1000 : undefined, + tag: payment.tag || payment.extra?.tag || null + } + } + + /** + * Map an LNbits payment status onto our three display states. + * + * LNbits serializes `PaymentState` as "pending" | "success" | "failed" + * (lnbits/core/models/payments.py). `Payment.pending` is a Python + * `@property`, and LNbits pins pydantic 1.x, which never serializes + * properties - so `pending` is absent from every REST and WebSocket + * payload. Reading it returned `undefined` for every payment, which is + * falsy, so everything rendered as "confirmed": pending payments looked + * settled and failed payments looked successful. + * + * An unrecognized status is treated as pending rather than confirmed: + * showing a payment as still in flight is the safe direction to be wrong. + */ + private mapPaymentStatus(payment: any): 'pending' | 'confirmed' | 'failed' { + switch (payment.status) { + case 'success': + return 'confirmed' + case 'failed': + return 'failed' + case 'pending': + return 'pending' + default: + console.warn('WalletService: unrecognized payment status', payment.status) + return 'pending' } } } -- 2.55.0