Skip to content

Commit 724ed91

Browse files
committed
fix: expire stale LNbits pending invoices
1 parent 8eee70f commit 724ed91

5 files changed

Lines changed: 161 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'nostream': patch
3+
---
4+
5+
Expire stale pending invoices when LNbits no longer has the invoice or reports it as unpaid past its expiry time.

src/app/maintenance-worker.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const CLEAR_OLD_EVENTS_TIMEOUT_MS = 5000
2121

2222
const logger = createLogger('maintenance-worker')
2323

24+
const isNotFoundError = (error: unknown): boolean => (error as any)?.response?.status === 404
25+
26+
const isExpiredInvoice = (invoice: { expiresAt?: Date | null }): boolean =>
27+
invoice.expiresAt instanceof Date && invoice.expiresAt.getTime() <= Date.now()
28+
2429
/**
2530
* Merge a re-verification outcome onto an existing verification row.
2631
*
@@ -168,6 +173,16 @@ export class MaintenanceWorker implements IRunnable {
168173
}
169174
successful++
170175
} catch (error) {
176+
if (isNotFoundError(error) && isExpiredInvoice(invoice)) {
177+
logger('marking expired invoice %s after payment processor returned 404', invoice.id)
178+
await this.paymentsService.updateInvoiceStatus({
179+
id: invoice.id,
180+
status: InvoiceStatus.EXPIRED,
181+
})
182+
successful++
183+
continue
184+
}
185+
171186
logger.error('Unable to update invoice from payment processor. Reason:', error)
172187
}
173188

src/payments-processors/lnbits-payment-processor.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class LNbitsPaymentsProcessor implements IPaymentsProcessor {
5353
})
5454
const invoice = new LNbitsInvoice()
5555
const data = response.data
56+
const expiresAt = new Date(data.details.expiry * 1000)
5657
invoice.id = data.details.payment_hash
5758
invoice.pubkey = data.details.extra.internalId
5859
invoice.bolt11 = data.details.bolt11
@@ -61,10 +62,16 @@ export class LNbitsPaymentsProcessor implements IPaymentsProcessor {
6162
invoice.amountPaid = BigInt(Math.floor(data.details.amount / 1000))
6263
}
6364
invoice.unit = InvoiceUnit.SATS
64-
invoice.status = data.paid ? InvoiceStatus.COMPLETED : InvoiceStatus.PENDING
65+
if (data.paid) {
66+
invoice.status = InvoiceStatus.COMPLETED
67+
} else if (expiresAt.getTime() <= Date.now()) {
68+
invoice.status = InvoiceStatus.EXPIRED
69+
} else {
70+
invoice.status = InvoiceStatus.PENDING
71+
}
6572
invoice.description = data.details.memo
6673
invoice.confirmedAt = data.paid ? new Date(data.details.time * 1000) : null
67-
invoice.expiresAt = new Date(data.details.expiry * 1000)
74+
invoice.expiresAt = expiresAt
6875
invoice.createdAt = new Date(data.details.time * 1000)
6976
invoice.updatedAt = new Date()
7077
return invoice

test/unit/app/maintenance-worker.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,53 @@ describe('MaintenanceWorker', () => {
440440
expect(maintenanceService.clearOldEvents).to.have.been.calledOnce
441441
expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnce
442442
})
443+
444+
it('marks an expired pending invoice as expired when the payment processor returns 404', async () => {
445+
const expiredInvoice = {
446+
...pendingInvoice,
447+
expiresAt: new Date(Date.now() - 60000),
448+
}
449+
const notFoundError = {
450+
response: { status: 404 },
451+
}
452+
settingsState.payments = { enabled: true } as any
453+
paymentsService.getPendingInvoices.resolves([expiredInvoice])
454+
paymentsService.getInvoiceFromPaymentsProcessor.rejects(notFoundError)
455+
456+
await (worker as any).onSchedule()
457+
458+
expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnceWithExactly({
459+
id: expiredInvoice.id,
460+
status: InvoiceStatus.EXPIRED,
461+
})
462+
})
463+
464+
it('keeps an expired pending invoice pending when the processor lookup fails without 404', async () => {
465+
const expiredInvoice = {
466+
...pendingInvoice,
467+
expiresAt: new Date(Date.now() - 60000),
468+
}
469+
settingsState.payments = { enabled: true } as any
470+
paymentsService.getPendingInvoices.resolves([expiredInvoice])
471+
paymentsService.getInvoiceFromPaymentsProcessor.rejects(new Error('network error'))
472+
473+
await (worker as any).onSchedule()
474+
475+
expect(paymentsService.updateInvoiceStatus).not.to.have.been.called
476+
})
477+
478+
it('keeps a non-expired pending invoice pending when the processor returns 404', async () => {
479+
const notFoundError = {
480+
response: { status: 404 },
481+
}
482+
settingsState.payments = { enabled: true } as any
483+
paymentsService.getPendingInvoices.resolves([pendingInvoice])
484+
paymentsService.getInvoiceFromPaymentsProcessor.rejects(notFoundError)
485+
486+
await (worker as any).onSchedule()
487+
488+
expect(paymentsService.updateInvoiceStatus).not.to.have.been.called
489+
})
443490
})
444491

445492
describe('onError', () => {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import chai from 'chai'
2+
import sinon from 'sinon'
3+
import sinonChai from 'sinon-chai'
4+
5+
import { InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice'
6+
import { LNbitsPaymentsProcessor } from '../../../src/payments-processors/lnbits-payment-processor'
7+
8+
chai.use(sinonChai)
9+
10+
const { expect } = chai
11+
12+
const invoiceResponse = (overrides: any = {}) => ({
13+
data: {
14+
paid: false,
15+
details: {
16+
payment_hash: 'lnbits-payment-hash',
17+
extra: {
18+
internalId: 'a'.repeat(64),
19+
},
20+
bolt11: 'lnbc1test',
21+
amount: 42000,
22+
memo: 'LNbits test invoice',
23+
time: Math.floor(Date.now() / 1000),
24+
expiry: Math.floor((Date.now() + 600000) / 1000),
25+
...overrides.details,
26+
},
27+
...overrides.data,
28+
},
29+
})
30+
31+
describe('LNbitsPaymentsProcessor', () => {
32+
const makeProcessor = (response: any) => {
33+
const httpClient = {
34+
get: sinon.stub().resolves(response),
35+
}
36+
37+
return {
38+
processor: new LNbitsPaymentsProcessor(httpClient as any, (() => ({})) as any),
39+
httpClient,
40+
}
41+
}
42+
43+
describe('getInvoice', () => {
44+
it('returns PENDING for unpaid invoices that have not expired', async () => {
45+
const { processor } = makeProcessor(invoiceResponse())
46+
47+
const invoice = await processor.getInvoice('lnbits-payment-hash')
48+
49+
expect(invoice.status).to.equal(InvoiceStatus.PENDING)
50+
expect(invoice.unit).to.equal(InvoiceUnit.SATS)
51+
})
52+
53+
it('returns EXPIRED for unpaid invoices past their LNbits expiry time', async () => {
54+
const { processor } = makeProcessor(
55+
invoiceResponse({
56+
details: {
57+
expiry: Math.floor((Date.now() - 60000) / 1000),
58+
},
59+
}),
60+
)
61+
62+
const invoice = await processor.getInvoice('lnbits-payment-hash')
63+
64+
expect(invoice.status).to.equal(InvoiceStatus.EXPIRED)
65+
})
66+
67+
it('keeps paid invoices COMPLETED even if the expiry time has passed', async () => {
68+
const { processor } = makeProcessor(
69+
invoiceResponse({
70+
data: {
71+
paid: true,
72+
},
73+
details: {
74+
expiry: Math.floor((Date.now() - 60000) / 1000),
75+
},
76+
}),
77+
)
78+
79+
const invoice = await processor.getInvoice('lnbits-payment-hash')
80+
81+
expect(invoice.status).to.equal(InvoiceStatus.COMPLETED)
82+
expect(invoice.amountPaid).to.equal(42n)
83+
})
84+
})
85+
})

0 commit comments

Comments
 (0)