From a85a5144d03ff1ad56c6e8eea8b41a9abd2c31a2 Mon Sep 17 00:00:00 2001 From: Morata Date: Thu, 16 Jul 2026 07:55:53 -0300 Subject: [PATCH] fix: mark expired incoming invoices as failed and add periodic cleanup (#3867) Co-authored-by: Vlad Stan --- lnbits/core/crud/__init__.py | 2 -- lnbits/core/crud/payments.py | 26 --------------------- lnbits/core/services/payments.py | 7 ++++++ tests/unit/test_services_payments.py | 34 ++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/lnbits/core/crud/__init__.py b/lnbits/core/crud/__init__.py index 39a8cf0d8..cb381dce2 100644 --- a/lnbits/core/crud/__init__.py +++ b/lnbits/core/crud/__init__.py @@ -25,7 +25,6 @@ from .payments import ( DateTrunc, check_internal, create_payment, - delete_expired_invoices, delete_wallet_payment, get_latest_payments_by_extension, get_payment, @@ -103,7 +102,6 @@ __all__ = [ "delete_accounts_no_wallets", "delete_admin_settings", "delete_dbversion", - "delete_expired_invoices", "delete_installed_extension", "delete_tinyurl", "delete_unused_wallets", diff --git a/lnbits/core/crud/payments.py b/lnbits/core/crud/payments.py index 8b15bf811..ac4f91cc5 100644 --- a/lnbits/core/crud/payments.py +++ b/lnbits/core/crud/payments.py @@ -238,32 +238,6 @@ async def get_payments_status_count() -> PaymentsStatusCount: ) -async def delete_expired_invoices( - conn: Connection | None = None, -) -> None: - # first we delete all invoices older than one month - - await (conn or db).execute( - # Timestamp placeholder is safe from SQL injection (not user input) - f""" - DELETE FROM apipayments - WHERE status = :status AND amount > 0 - AND time < {db.timestamp_placeholder("delta")} - """, # noqa: S608 - {"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)}, - ) - # then we delete all invoices whose expiry date is in the past - await (conn or db).execute( - # Timestamp placeholder is safe from SQL injection (not user input) - f""" - DELETE FROM apipayments - WHERE status = :status AND amount > 0 - AND expiry < {db.timestamp_placeholder("now")} - """, # noqa: S608 - {"status": f"{PaymentState.PENDING}", "now": int(time())}, - ) - - async def create_payment( checking_id: str, data: CreatePayment, diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 0b8d6b56e..712b8c395 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -374,6 +374,13 @@ async def update_pending_payments(wallet_id: str): async def update_pending_payment( payment: Payment, conn: Connection | None = None ) -> Payment: + if payment.is_in and payment.is_expired: + payment.status = PaymentState.FAILED + payment.labels.append("expired") + await update_payment(payment, conn=conn) + logger.info(f"invoice {payment.checking_id} expired, marked as failed") + return payment + status = await check_payment_status(payment) if status.failed: payment.status = PaymentState.FAILED diff --git a/tests/unit/test_services_payments.py b/tests/unit/test_services_payments.py index c79c1fc1d..a55ee06b4 100644 --- a/tests/unit/test_services_payments.py +++ b/tests/unit/test_services_payments.py @@ -111,6 +111,36 @@ async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFix assert bulk_statuses == {PaymentState.FAILED, PaymentState.SUCCESS} +@pytest.mark.anyio +async def test_update_pending_payment_marks_expired_incoming_invoice_failed( + app, + mocker: MockerFixture, +): + wallet = await _create_wallet() + checking_id = await _create_payment( + wallet, + expiry=datetime.now(timezone.utc) - timedelta(seconds=1), + labels=["test"], + ) + payment = await get_payment(checking_id) + check_status_mock = mocker.patch( + "lnbits.core.services.payments.check_payment_status", + mocker.AsyncMock( + side_effect=AssertionError("expired invoices should not be checked") + ), + ) + + updated_payment = await update_pending_payment(payment) + + assert updated_payment.status == PaymentState.FAILED + assert updated_payment.labels == ["test", "expired"] + check_status_mock.assert_not_awaited() + + stored_payment = await get_payment(checking_id) + assert stored_payment.status == PaymentState.FAILED + assert stored_payment.labels == ["test", "expired"] + + @pytest.mark.anyio async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items( mocker: MockerFixture, @@ -451,6 +481,8 @@ async def _create_payment( payment_hash: str | None = None, fee: int = 0, time: datetime | None = None, + expiry: datetime | None = None, + labels: list[str] | None = None, ) -> str: checking_id = checking_id or f"checking_{uuid4().hex[:8]}" payment_hash = payment_hash or uuid4().hex @@ -462,7 +494,9 @@ async def _create_payment( bolt11=f"bolt11-{checking_id}", amount_msat=amount_msat, memo="memo", + expiry=expiry, fee=fee, + labels=labels, ), status=status, )