fix(wallet): read payment status from status, not the unserialized pending #161
1 changed files with 54 additions and 64 deletions
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.
commit
9c9a29f93a
|
|
@ -352,35 +352,9 @@ export default class WalletService extends BaseService {
|
||||||
const payments = await response.json()
|
const payments = await response.json()
|
||||||
|
|
||||||
// Transform to our transaction format
|
// Transform to our transaction format
|
||||||
this._transactions.value = payments.map((payment: any) => {
|
this._transactions.value = payments
|
||||||
let timestamp = new Date()
|
.map((payment: any) => this.mapPaymentToTransaction(payment))
|
||||||
|
.sort((a: PaymentTransaction, b: PaymentTransaction) =>
|
||||||
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()
|
b.timestamp.getTime() - a.timestamp.getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -478,51 +452,67 @@ export default class WalletService extends BaseService {
|
||||||
* Map LNbits payment object to our transaction format
|
* Map LNbits payment object to our transaction format
|
||||||
*/
|
*/
|
||||||
private mapPaymentToTransaction(payment: any): PaymentTransaction {
|
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()
|
let timestamp = new Date()
|
||||||
if (payment.time) {
|
if (payment.time) {
|
||||||
if (typeof payment.time === 'string') {
|
if (typeof payment.time === 'number') {
|
||||||
// ISO string format
|
|
||||||
timestamp = new Date(payment.time)
|
|
||||||
} else if (typeof payment.time === 'number') {
|
|
||||||
// Unix timestamp (seconds)
|
|
||||||
timestamp = new Date(payment.time * 1000)
|
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
|
// `amount` is signed millisats: positive incoming, negative outgoing.
|
||||||
const amountSats = Math.abs(payment.amount) / 1000
|
const amountMsat = Number(payment.amount ?? 0)
|
||||||
|
const amountSats = Math.abs(amountMsat) / 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'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// `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 {
|
return {
|
||||||
id: payment.payment_hash || payment.checking_id || payment.id,
|
id: payment.payment_hash || payment.checking_id || payment.id,
|
||||||
amount: amountSats,
|
amount: amountSats,
|
||||||
description: payment.description || payment.memo || 'Payment',
|
description: payment.memo || payment.description || 'No description',
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
type: payment.amount > 0 ? 'received' : 'sent',
|
type: amountMsat > 0 ? 'received' : 'sent',
|
||||||
status: status,
|
status: this.mapPaymentStatus(payment),
|
||||||
fee: payment.fee_msat ? payment.fee_msat / 1000 : undefined,
|
fee: feeMsat > 0 ? feeMsat / 1000 : undefined,
|
||||||
tag: payment.tag || null
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue