diff --git a/.changeset/expire-stale-lnbits-invoices.md b/.changeset/expire-stale-lnbits-invoices.md new file mode 100644 index 00000000..1d15fb32 --- /dev/null +++ b/.changeset/expire-stale-lnbits-invoices.md @@ -0,0 +1,5 @@ +--- +'nostream': patch +--- + +Expire stale pending invoices when LNbits no longer has the invoice or reports it as unpaid past its expiry time. diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index f0a87499..a58f8055 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -12,6 +12,7 @@ import { createLogger } from '../factories/logger-factory' import { delayMs } from '../utils/misc' import { INip05VerificationRepository } from '../@types/repositories' import { InvoiceStatus } from '../@types/invoice' +import { isExpiredInvoice } from '../utils/invoice' import { Nip05Verification } from '../@types/nip05' import { Settings } from '../@types/settings' @@ -21,6 +22,9 @@ const CLEAR_OLD_EVENTS_TIMEOUT_MS = 5000 const logger = createLogger('maintenance-worker') +const isNotFoundError = (error: unknown): boolean => + (error as any)?.response?.status === 404 + /** * Merge a re-verification outcome onto an existing verification row. * @@ -168,6 +172,16 @@ export class MaintenanceWorker implements IRunnable { } successful++ } catch (error) { + if (isNotFoundError(error) && isExpiredInvoice(invoice)) { + logger('marking expired invoice %s after payment processor returned 404', invoice.id) + await this.paymentsService.updateInvoiceStatus({ + id: invoice.id, + status: InvoiceStatus.EXPIRED, + }) + successful++ + continue + } + logger.error('Unable to update invoice from payment processor. Reason:', error) } diff --git a/src/payments-processors/lnbits-payment-processor.ts b/src/payments-processors/lnbits-payment-processor.ts index 10037bd5..f14d2af7 100644 --- a/src/payments-processors/lnbits-payment-processor.ts +++ b/src/payments-processors/lnbits-payment-processor.ts @@ -5,6 +5,7 @@ import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' import { AxiosInstance } from 'axios' import { createLogger } from '../factories/logger-factory' import { Factory } from '../@types/base' +import { isExpiredInvoice } from '../utils/invoice' import { Pubkey } from '../@types/base' import { Settings } from '../@types/settings' @@ -61,10 +62,16 @@ export class LNbitsPaymentsProcessor implements IPaymentsProcessor { invoice.amountPaid = BigInt(Math.floor(data.details.amount / 1000)) } invoice.unit = InvoiceUnit.SATS - invoice.status = data.paid ? InvoiceStatus.COMPLETED : InvoiceStatus.PENDING + invoice.expiresAt = new Date(data.details.expiry * 1000) + if (data.paid) { + invoice.status = InvoiceStatus.COMPLETED + } else if (isExpiredInvoice(invoice)) { + invoice.status = InvoiceStatus.EXPIRED + } else { + invoice.status = InvoiceStatus.PENDING + } invoice.description = data.details.memo invoice.confirmedAt = data.paid ? new Date(data.details.time * 1000) : null - invoice.expiresAt = new Date(data.details.expiry * 1000) invoice.createdAt = new Date(data.details.time * 1000) invoice.updatedAt = new Date() return invoice diff --git a/src/utils/invoice.ts b/src/utils/invoice.ts new file mode 100644 index 00000000..30c546b5 --- /dev/null +++ b/src/utils/invoice.ts @@ -0,0 +1,2 @@ +export const isExpiredInvoice = (invoice: { expiresAt?: Date | null }): boolean => + invoice.expiresAt instanceof Date && invoice.expiresAt.getTime() <= Date.now() diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index 7eca639b..7c48bd29 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -440,6 +440,53 @@ describe('MaintenanceWorker', () => { expect(maintenanceService.clearOldEvents).to.have.been.calledOnce expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnce }) + + it('marks an expired pending invoice as expired when the payment processor returns 404', async () => { + const expiredInvoice = { + ...pendingInvoice, + expiresAt: new Date(Date.now() - 60000), + } + const notFoundError = { + response: { status: 404 }, + } + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([expiredInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.rejects(notFoundError) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnceWithExactly({ + id: expiredInvoice.id, + status: InvoiceStatus.EXPIRED, + }) + }) + + it('keeps an expired pending invoice pending when the processor lookup fails without 404', async () => { + const expiredInvoice = { + ...pendingInvoice, + expiresAt: new Date(Date.now() - 60000), + } + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([expiredInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.rejects(new Error('network error')) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).not.to.have.been.called + }) + + it('keeps a non-expired pending invoice pending when the processor returns 404', async () => { + const notFoundError = { + response: { status: 404 }, + } + settingsState.payments = { enabled: true } as any + paymentsService.getPendingInvoices.resolves([pendingInvoice]) + paymentsService.getInvoiceFromPaymentsProcessor.rejects(notFoundError) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).not.to.have.been.called + }) }) describe('onError', () => { diff --git a/test/unit/payments-processors/lnbits-payment-processor.spec.ts b/test/unit/payments-processors/lnbits-payment-processor.spec.ts new file mode 100644 index 00000000..d3f12725 --- /dev/null +++ b/test/unit/payments-processors/lnbits-payment-processor.spec.ts @@ -0,0 +1,85 @@ +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { LNbitsPaymentsProcessor } from '../../../src/payments-processors/lnbits-payment-processor' + +chai.use(sinonChai) + +const { expect } = chai + +const invoiceResponse = (overrides: any = {}) => ({ + data: { + paid: false, + details: { + payment_hash: 'lnbits-payment-hash', + extra: { + internalId: 'a'.repeat(64), + }, + bolt11: 'lnbc1test', + amount: 42000, + memo: 'LNbits test invoice', + time: Math.floor(Date.now() / 1000), + expiry: Math.floor((Date.now() + 600000) / 1000), + ...overrides.details, + }, + ...overrides.data, + }, +}) + +describe('LNbitsPaymentsProcessor', () => { + const makeProcessor = (response: any) => { + const httpClient = { + get: sinon.stub().resolves(response), + } + + return { + processor: new LNbitsPaymentsProcessor(httpClient as any, (() => ({})) as any), + httpClient, + } + } + + describe('getInvoice', () => { + it('returns PENDING for unpaid invoices that have not expired', async () => { + const { processor } = makeProcessor(invoiceResponse()) + + const invoice = await processor.getInvoice('lnbits-payment-hash') + + expect(invoice.status).to.equal(InvoiceStatus.PENDING) + expect(invoice.unit).to.equal(InvoiceUnit.SATS) + }) + + it('returns EXPIRED for unpaid invoices past their LNbits expiry time', async () => { + const { processor } = makeProcessor( + invoiceResponse({ + details: { + expiry: Math.floor((Date.now() - 60000) / 1000), + }, + }), + ) + + const invoice = await processor.getInvoice('lnbits-payment-hash') + + expect(invoice.status).to.equal(InvoiceStatus.EXPIRED) + }) + + it('keeps paid invoices COMPLETED even if the expiry time has passed', async () => { + const { processor } = makeProcessor( + invoiceResponse({ + data: { + paid: true, + }, + details: { + expiry: Math.floor((Date.now() - 60000) / 1000), + }, + }), + ) + + const invoice = await processor.getInvoice('lnbits-payment-hash') + + expect(invoice.status).to.equal(InvoiceStatus.COMPLETED) + expect(invoice.amountPaid).to.equal(42n) + }) + }) +})