fix(wallet): read payment status from status, not the unserialized pending

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.
This commit is contained in:
Padreug 2026-09-22 22:40:38 +02:00
commit 9c9a29f93a

View file

@ -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'
}
}
}