refactor: add status column to apipayments (#2537)

* refactor: add status column to apipayments

keep track of the payment status with an enum and persist it as string
to db. `pending`, `success`, `failed`.

- database migration
- remove deleting of payments, failed payments stay
This commit is contained in:
dni ⚡ 2024-07-24 15:47:26 +02:00 committed by GitHub
parent b14d36a0aa
commit 8f761dfd0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 301 additions and 258 deletions

View file

@ -12,7 +12,7 @@ from fastapi.exceptions import HTTPException
from loguru import logger from loguru import logger
from packaging import version from packaging import version
from lnbits.core.models import Payment, User from lnbits.core.models import Payment, PaymentState, User
from lnbits.core.services import check_admin_settings from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import ( from lnbits.core.views.extension_api import (
api_install_extension, api_install_extension,
@ -216,10 +216,12 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending") @db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.") @click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro @coro
async def database_revert_payment(checking_id: str, pending: bool = True): async def database_revert_payment(checking_id: str):
"""Mark wallet as deleted""" """Mark payment as pending"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn) await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
@db.command("cleanup-accounts") @db.command("cleanup-accounts")

View file

@ -8,6 +8,7 @@ import shortuuid
from passlib.context import CryptContext from passlib.context import CryptContext
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
from lnbits.extension_manager import ( from lnbits.extension_manager import (
InstallableExtension, InstallableExtension,
@ -738,7 +739,7 @@ async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: in
rows = await db.fetchall( rows = await db.fetchall(
f""" f"""
SELECT * FROM apipayments SELECT * FROM apipayments
WHERE pending = false WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE ? AND extra LIKE ?
AND extra LIKE ? AND extra LIKE ?
ORDER BY time DESC LIMIT {limit} ORDER BY time DESC LIMIT {limit}
@ -782,9 +783,11 @@ async def get_payments_paginated(
if complete and pending: if complete and pending:
pass pass
elif complete: elif complete:
clause.append("((amount > 0 AND pending = false) OR amount < 0)") clause.append(
f"((amount > 0 AND status = '{PaymentState.SUCCESS}') OR amount < 0)"
)
elif pending: elif pending:
clause.append("pending = true") clause.append(f"status = '{PaymentState.PENDING}'")
else: else:
pass pass
@ -857,7 +860,7 @@ async def delete_expired_invoices(
await (conn or db).execute( await (conn or db).execute(
f""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE pending = true AND amount > 0 WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)} AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
""" """
) )
@ -865,7 +868,7 @@ async def delete_expired_invoices(
await (conn or db).execute( await (conn or db).execute(
f""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE pending = true AND amount > 0 WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_now} AND expiry < {db.timestamp_now}
""" """
) )
@ -884,9 +887,9 @@ async def create_payment(
amount: int, amount: int,
memo: str, memo: str,
fee: int = 0, fee: int = 0,
status: PaymentState = PaymentState.PENDING,
preimage: Optional[str] = None, preimage: Optional[str] = None,
expiry: Optional[datetime.datetime] = None, expiry: Optional[datetime.datetime] = None,
pending: bool = True,
extra: Optional[Dict] = None, extra: Optional[Dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
@ -900,8 +903,8 @@ async def create_payment(
""" """
INSERT INTO apipayments INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage, (wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra, webhook, expiry) amount, status, memo, fee, extra, webhook, expiry, pending)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
wallet_id, wallet_id,
@ -910,7 +913,7 @@ async def create_payment(
payment_hash, payment_hash,
preimage, preimage,
amount, amount,
pending, status.value,
memo, memo,
fee, fee,
( (
@ -920,6 +923,7 @@ async def create_payment(
), ),
webhook, webhook,
db.datetime_to_timestamp(expiry) if expiry else None, db.datetime_to_timestamp(expiry) if expiry else None,
False, # TODO: remove this in next release
), ),
) )
@ -930,17 +934,17 @@ async def create_payment(
async def update_payment_status( async def update_payment_status(
checking_id: str, pending: bool, conn: Optional[Connection] = None checking_id: str, status: PaymentState, conn: Optional[Connection] = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?", "UPDATE apipayments SET status = ? WHERE checking_id = ?",
(pending, checking_id), (status.value, checking_id),
) )
async def update_payment_details( async def update_payment_details(
checking_id: str, checking_id: str,
pending: Optional[bool] = None, status: Optional[PaymentState] = None,
fee: Optional[int] = None, fee: Optional[int] = None,
preimage: Optional[str] = None, preimage: Optional[str] = None,
new_checking_id: Optional[str] = None, new_checking_id: Optional[str] = None,
@ -952,9 +956,9 @@ async def update_payment_details(
if new_checking_id is not None: if new_checking_id is not None:
set_clause.append("checking_id = ?") set_clause.append("checking_id = ?")
set_variables.append(new_checking_id) set_variables.append(new_checking_id)
if pending is not None: if status is not None:
set_clause.append("pending = ?") set_clause.append("status = ?")
set_variables.append(pending) set_variables.append(status.value)
if fee is not None: if fee is not None:
set_clause.append("fee = ?") set_clause.append("fee = ?")
set_variables.append(fee) set_variables.append(fee)
@ -1000,16 +1004,6 @@ async def update_payment_extra(
) )
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
await payment.check_status()
DateTrunc = Literal["hour", "day", "month"] DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = { sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00", "hour": "%Y-%m-%d %H:00:00",
@ -1025,7 +1019,7 @@ async def get_payments_history(
) -> List[PaymentHistoryPoint]: ) -> List[PaymentHistoryPoint]:
if not filters: if not filters:
filters = Filters() filters = Filters()
where = ["(pending = False OR amount < 0)"] where = [f"(status = '{PaymentState.SUCCESS}' OR amount < 0)"]
values = [] values = []
if wallet_id: if wallet_id:
where.append("wallet = ?") where.append("wallet = ?")
@ -1090,9 +1084,9 @@ async def check_internal(
otherwise None otherwise None
""" """
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
""" f"""
SELECT checking_id FROM apipayments SELECT checking_id FROM apipayments
WHERE hash = ? AND pending AND amount > 0 WHERE hash = ? AND status = '{PaymentState.PENDING}' AND amount > 0
""", """,
(payment_hash,), (payment_hash,),
) )
@ -1111,15 +1105,14 @@ async def check_internal_pending(
""" """
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
""" """
SELECT pending FROM apipayments SELECT status FROM apipayments
WHERE hash = ? AND amount > 0 WHERE hash = ? AND amount > 0
""", """,
(payment_hash,), (payment_hash,),
) )
if not row: if not row:
return True return True
else: return row["status"] == PaymentState.PENDING.value
return row["pending"]
async def mark_webhook_sent(payment_hash: str, status: int) -> None: async def mark_webhook_sent(payment_hash: str, status: int) -> None:

View file

@ -520,3 +520,31 @@ async def m020_add_column_column_to_user_extensions(db):
Adds extra column to user extensions. Adds extra column to user extensions.
""" """
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT") await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db):
"""
Adds success and failed columns to apipayments.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
# set all not pending to success true, failed payments were deleted until now
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet
"""
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")

View file

@ -12,15 +12,17 @@ from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey from ecdsa import SECP256k1, SigningKey
from fastapi import Query from fastapi import Query
from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from lnbits.db import Connection, FilterModel, FromRowModel from lnbits.db import FilterModel, FromRowModel
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentStatus,
)
class BaseWallet(BaseModel): class BaseWallet(BaseModel):
@ -199,9 +201,20 @@ class LoginUsernamePassword(BaseModel):
password: str password: str
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class Payment(FromRowModel): class Payment(FromRowModel):
checking_id: str status: str
# TODO should be removed in the future, backward compatibility
pending: bool pending: bool
checking_id: str
amount: int amount: int
fee: int fee: int
memo: Optional[str] memo: Optional[str]
@ -215,6 +228,14 @@ class Payment(FromRowModel):
webhook: Optional[str] webhook: Optional[str]
webhook_status: Optional[int] webhook_status: Optional[int]
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: Row):
return cls( return cls(
@ -223,7 +244,9 @@ class Payment(FromRowModel):
bolt11=row["bolt11"] or "", bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64, preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"), extra=json.loads(row["extra"] or "{}"),
pending=row["pending"], status=row["status"],
# TODO should be removed in the future, backward compatibility
pending=row["status"] == PaymentState.PENDING.value,
amount=row["amount"], amount=row["amount"],
fee=row["fee"], fee=row["fee"],
memo=row["memo"], memo=row["memo"],
@ -264,80 +287,16 @@ class Payment(FromRowModel):
def is_uncheckable(self) -> bool: def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_") return self.checking_id.startswith("internal_")
async def update_status( async def check_status(self) -> PaymentStatus:
self,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable: if self.is_uncheckable:
return PaymentPendingStatus() return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
funding_source = get_funding_source() funding_source = get_funding_source()
if self.is_out: if self.is_out:
status = await funding_source.get_payment_status(self.checking_id) status = await funding_source.get_payment_status(self.checking_id)
else: else:
status = await funding_source.get_invoice_status(self.checking_id) status = await funding_source.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"] __search_fields__ = ["memo", "amount"]

View file

@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import httpx import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket from fastapi import Depends, WebSocket
@ -50,7 +51,6 @@ from .crud import (
create_admin_settings, create_admin_settings,
create_payment, create_payment,
create_wallet, create_wallet,
delete_wallet_payment,
get_account, get_account,
get_account_by_email, get_account_by_email,
get_account_by_username, get_account_by_username,
@ -67,7 +67,7 @@ from .crud import (
update_user_extension, update_user_extension,
) )
from .helpers import to_valid_user_id from .helpers import to_valid_user_id
from .models import BalanceDelta, Payment, User, UserConfig, Wallet from .models import BalanceDelta, Payment, PaymentState, User, UserConfig, Wallet
class PaymentError(Exception): class PaymentError(Exception):
@ -283,32 +283,19 @@ async def pay_invoice(
new_payment = await create_payment( new_payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat), fee=0 + abs(fee_reserve_total_msat),
pending=False, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
**payment_kwargs, **payment_kwargs,
) )
else: else:
fee_reserve_total_msat = fee_reserve_total( new_payment = await _create_external_payment(
invoice.amount_msat, internal=False temp_id, invoice.amount_msat, conn=conn, **payment_kwargs
) )
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check # do the balance check
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched" assert wallet, "Wallet for balancecheck could not be fetched"
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id) _check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if extra and "tag" in extra: if extra and "tag" in extra:
@ -325,7 +312,9 @@ async def pay_invoice(
# the payer has enough to deduct from # the payer has enough to deduct from
async with db.connect() as conn: async with db.connect() as conn:
await update_payment_status( await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
) )
await send_payment_notification(wallet, new_payment) await send_payment_notification(wallet, new_payment)
@ -350,15 +339,18 @@ async def pay_invoice(
f" {payment.checking_id})" f" {payment.checking_id})"
) )
logger.debug(f"backend: pay_invoice finished {temp_id}") logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
logger.debug(f"backend: pay_invoice response {payment}")
if payment.checking_id and payment.ok is not False: if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)! # payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}") logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn: async with db.connect() as conn:
await update_payment_details( await update_payment_details(
checking_id=temp_id, checking_id=temp_id,
pending=payment.ok is not True, status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-( fee=-(
abs(payment.fee_msat if payment.fee_msat else 0) abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat) + abs(service_fee_msat)
@ -376,10 +368,13 @@ async def pay_invoice(
logger.debug(f"payment successful {payment.checking_id}") logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False: elif payment.checking_id is None and payment.ok is False:
# payment failed # payment failed
logger.warning("backend sent payment failure") logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn: async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}") await update_payment_status(
await delete_wallet_payment(temp_id, wallet_id, conn=conn) checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError( raise PaymentError(
f"Payment failed: {payment.error_message}" f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message.", or "Payment failed, but backend didn't give us an error message.",
@ -401,11 +396,62 @@ async def pay_invoice(
checking_id="service_fee" + temp_id, checking_id="service_fee" + temp_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
pending=False, status=PaymentState.SUCCESS,
) )
return invoice.payment_hash return invoice.payment_hash
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
conn: Optional[Connection],
**payment_kwargs,
) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(temp_id, conn=conn)
if old_payment:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance( def _check_wallet_balance(
wallet: Wallet, wallet: Wallet,
fee_reserve_total_msat: int, fee_reserve_total_msat: int,
@ -617,12 +663,11 @@ async def check_transaction_status(
) )
if not payment: if not payment:
return PaymentPendingStatus() return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee) return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status() return await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to # WARN: this same value must be used for balance check and passed to
@ -680,7 +725,9 @@ async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn: async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn) checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved" assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(checking_id=checking_id, pending=False, conn=conn) await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@ -856,3 +903,23 @@ async def get_balance_delta() -> BalanceDelta:
lnbits_balance_msats=lnbits_balance, lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat, node_balance_msats=status.balance_msat,
) )
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)

View file

@ -52,13 +52,12 @@ from ..crud import (
get_payments_paginated, get_payments_paginated,
get_standalone_payment, get_standalone_payment,
get_wallet_for_key, get_wallet_for_key,
update_pending_payments,
) )
from ..services import ( from ..services import (
check_transaction_status,
create_invoice, create_invoice,
fee_reserve_total, fee_reserve_total,
pay_invoice, pay_invoice,
update_pending_payments,
) )
from ..tasks import api_invoice_listeners from ..tasks import api_invoice_listeners
@ -402,15 +401,8 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
) )
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment( if payment.success:
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment} return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
@ -424,12 +416,12 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return { return {
"paid": not payment.pending, "paid": payment.success,
"status": f"{status!s}", "status": f"{status!s}",
"preimage": payment.preimage, "preimage": payment.preimage,
"details": payment, "details": payment,
} }
return {"paid": not payment.pending, "preimage": payment.preimage} return {"paid": payment.success, "preimage": payment.preimage}
@payment_router.post("/decode", status_code=HTTPStatus.OK) @payment_router.post("/decode", status_code=HTTPStatus.OK)

View file

@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
) )
elif not payment.pending: # TODO: refactor to use PaymentState
if payment.success:
return {"status": "paid"} return {"status": "paid"}
try: try:

File diff suppressed because one or more lines are too long

View file

@ -247,7 +247,7 @@ window.LNbits = {
payment: function (data) { payment: function (data) {
obj = { obj = {
checking_id: data.checking_id, checking_id: data.checking_id,
pending: data.pending, status: data.status,
amount: data.amount, amount: data.amount,
fee: data.fee, fee: data.fee,
memo: data.memo, memo: data.memo,
@ -280,7 +280,9 @@ window.LNbits = {
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat) obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = obj.amount > 0 obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0 obj.isOut = obj.amount < 0
obj.isPaid = !obj.pending obj.isPending = obj.status === 'pending'
obj.isPaid = obj.status === 'success'
obj.isFailed = obj.status === 'failed'
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase() obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
return obj return obj
} }

View file

@ -254,6 +254,16 @@ Vue.component('payment-list', {
:color="props.row.isOut ? 'pink' : 'green'" :color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand" @click="props.expand = !props.expand"
></q-icon> ></q-icon>
<q-icon
v-else-if="props.row.isFailed"
name="warning"
color="yellow"
@click="props.expand = !props.expand"
>
<q-tooltip
><span>failed</span
></q-tooltip>
</q-icon>
<q-icon <q-icon
v-else v-else
name="settings_ethernet" name="settings_ethernet"
@ -319,7 +329,7 @@ Vue.component('payment-list', {
<q-dialog v-model="props.expand" :props="props" position="top"> <q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending"> <div v-if="props.row.isIn && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon> <q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span> <span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details <lnbits-payment-details
@ -353,6 +363,13 @@ Vue.component('payment-list', {
></q-btn> ></q-btn>
</div> </div>
</div> </div>
<div v-else-if="props.row.isOut && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn"> <div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon <q-icon
size="18px" size="18px"
@ -375,9 +392,9 @@ Vue.component('payment-list', {
:payment="props.row" :payment="props.row"
></lnbits-payment-details> ></lnbits-payment-details>
</div> </div>
<div v-else-if="props.row.isOut && props.row.pending"> <div v-else-if="props.row.isFailed">
<q-icon name="settings_ethernet" color="grey"></q-icon> <q-icon name="warning" color="yellow"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span> <span>Payment failed</span>
<lnbits-payment-details <lnbits-payment-details
:payment="props.row" :payment="props.row"
></lnbits-payment-details> ></lnbits-payment-details>

View file

@ -11,11 +11,13 @@ from py_vapid import Vapid
from pywebpush import WebPushException, webpush from pywebpush import WebPushException, webpush
from lnbits.core.crud import ( from lnbits.core.crud import (
delete_expired_invoices,
delete_webpush_subscriptions, delete_webpush_subscriptions,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
update_payment_details,
update_payment_status,
) )
from lnbits.core.models import PaymentState
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
@ -131,45 +133,41 @@ async def check_pending_payments():
the backend and also to delete expired invoices. Incoming payments will be the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly. checked only once, outgoing pending payments will be checked regularly.
""" """
outgoing = True
incoming = True
while settings.lnbits_running: while settings.lnbits_running:
logger.info(
f"Task: checking all pending payments (incoming={incoming},"
f" outgoing={outgoing}) of last 15 days"
)
start_time = time.time() start_time = time.time()
pending_payments = await get_payments( pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False, complete=False,
pending=True, pending=True,
outgoing=outgoing,
incoming=incoming,
exclude_uncheckable=True, exclude_uncheckable=True,
) )
for payment in pending_payments: count = len(pending_payments)
await payment.check_status() if count > 0:
await asyncio.sleep(0.01) # to avoid complete blocking logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
logger.info( status = await payment.check_status()
f"Task: pending check finished for {len(pending_payments)} payments" prefix = f"payment ({i+1} / {count})"
f" (took {time.time() - start_time:0.3f} s)" if status.failed:
) await update_payment_status(
# we delete expired invoices once upon the first pending check payment.checking_id, status=PaymentState.FAILED
if incoming: )
logger.debug("Task: deleting all expired invoices") logger.debug(f"{prefix} failed {payment.checking_id}")
start_time = time.time() elif status.success:
await delete_expired_invoices() await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info( logger.info(
"Task: expired invoice deletion finished (took" f"Task: pending check finished for {count} payments"
f" {time.time() - start_time:0.3f} s)" f" (took {time.time() - start_time:0.3f} s)"
) )
# after the first check we will only check outgoing, not incoming
# that will be handled by the global invoice listeners, hopefully
incoming = False
await asyncio.sleep(60 * 30) # every 30 minutes await asyncio.sleep(60 * 30) # every 30 minutes
@ -183,7 +181,13 @@ async def invoice_callback_dispatcher(checking_id: str):
logger.trace( logger.trace(
f"invoice listeners: sending invoice callback for payment {checking_id}" f"invoice listeners: sending invoice callback for payment {checking_id}"
) )
await payment.check_status() status = await payment.check_status()
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
for name, send_chan in invoice_listeners.items(): for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`") logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment) await send_chan.put(payment)
@ -204,8 +208,11 @@ async def send_push_notification(subscription, title, body, url=""):
{"aud": "", "sub": "mailto:alan@lnbits.com"}, {"aud": "", "sub": "mailto:alan@lnbits.com"},
) )
except WebPushException as e: except WebPushException as e:
if e.response.status_code == HTTPStatus.GONE: if e.response and e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions # cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint) await delete_webpush_subscriptions(subscription.endpoint)
else: else:
logger.error(f"failed sending push notification: {e.response.text}") logger.error(
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)

View file

@ -19,7 +19,7 @@ from lnbits.core.crud import (
get_user, get_user,
update_payment_status, update_payment_status,
) )
from lnbits.core.models import CreateInvoice from lnbits.core.models import CreateInvoice, PaymentState
from lnbits.core.services import update_wallet_balance from lnbits.core.services import update_wallet_balance
from lnbits.core.views.payment_api import api_payments_create_invoice from lnbits.core.views.payment_api import api_payments_create_invoice
from lnbits.db import DB_TYPE, SQLITE, Database from lnbits.db import DB_TYPE, SQLITE, Database
@ -199,7 +199,9 @@ async def fake_payments(client, adminkey_headers_from):
"/api/v1/payments", headers=adminkey_headers_from, json=invoice.dict() "/api/v1/payments", headers=adminkey_headers_from, json=invoice.dict()
) )
assert response.is_success assert response.is_success
await update_payment_status(response.json()["checking_id"], pending=False) await update_payment_status(
response.json()["checking_id"], status=PaymentState.SUCCESS
)
params = {"time[ge]": ts, "time[le]": time()} params = {"time[ge]": ts, "time[le]": time()}
return fake_data, params return fake_data, params

View file

@ -5,9 +5,8 @@ import pytest
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.crud import get_standalone_payment, update_payment_details from lnbits.core.crud import get_standalone_payment, update_payment_details
from lnbits.core.models import CreateInvoice, Payment from lnbits.core.models import CreateInvoice, Payment, PaymentState
from lnbits.core.services import fee_reserve_total, get_balance_delta from lnbits.core.services import fee_reserve_total, get_balance_delta
from lnbits.core.views.payment_api import api_payment
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from ..helpers import is_fake, is_regtest from ..helpers import is_fake, is_regtest
@ -88,11 +87,12 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
return return
assert found_checking_id assert found_checking_id
task = asyncio.create_task(listen()) async def pay():
await asyncio.sleep(1) await asyncio.sleep(3)
pay_real_invoice(invoice["payment_request"]) pay_real_invoice(invoice["payment_request"])
await asyncio.wait_for(task, timeout=10)
await asyncio.gather(listen(), pay())
await asyncio.sleep(3)
response = await client.get( response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
) )
@ -127,10 +127,11 @@ async def test_pay_real_invoice_set_pending_and_check_state(
assert len(invoice["checking_id"]) > 0 assert len(invoice["checking_id"]) > 0
# check the payment status # check the payment status
response = await api_payment( response = await client.get(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"] f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
) )
assert response["paid"] payment_status = response.json()
assert payment_status["paid"]
# make sure that the backend also thinks it's paid # make sure that the backend also thinks it's paid
funding_source = get_funding_source() funding_source = get_funding_source()
@ -140,22 +141,9 @@ async def test_pay_real_invoice_set_pending_and_check_state(
# get the outgoing payment from the db # get the outgoing payment from the db
payment = await get_standalone_payment(invoice["payment_hash"]) payment = await get_standalone_payment(invoice["payment_hash"])
assert payment assert payment
assert payment.success
assert payment.pending is False assert payment.pending is False
# set the outgoing invoice to pending
await update_payment_details(payment.checking_id, pending=True)
payment_pending = await get_standalone_payment(invoice["payment_hash"])
assert payment_pending
assert payment_pending.pending is True
# check the outgoing payment status
await payment.check_status()
payment_not_pending = await get_standalone_payment(invoice["payment_hash"])
assert payment_not_pending
assert payment_not_pending.pending is False
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="this only works in regtest") @pytest.mark.skipif(is_fake, reason="this only works in regtest")
@ -229,9 +217,11 @@ async def test_pay_hold_invoice_check_pending_and_fail(
await asyncio.sleep(1) await asyncio.sleep(1)
# payment should not be in database anymore # payment should be in database as failed
payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash) payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash)
assert payment_db_after_settlement is None assert payment_db_after_settlement
assert payment_db_after_settlement.pending is False
assert payment_db_after_settlement.failed is True
@pytest.mark.asyncio @pytest.mark.asyncio
@ -272,15 +262,10 @@ async def test_pay_hold_invoice_check_pending_and_fail_cancel_payment_task_in_me
payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash) payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash)
assert payment_db_after_settlement is not None assert payment_db_after_settlement is not None
# status should still be available and be False # payment is failed
status = await payment_db.check_status() status = await payment_db.check_status()
assert not status.paid assert not status.paid
assert status.failed
# now the payment should be gone after the status check
# payment_db_after_status_check = await get_standalone_payment(
# invoice_obj.payment_hash
# )
# assert payment_db_after_status_check is None
@pytest.mark.asyncio @pytest.mark.asyncio
@ -304,10 +289,11 @@ async def test_receive_real_invoice_set_pending_and_check_state(
) )
assert response.status_code < 300 assert response.status_code < 300
invoice = response.json() invoice = response.json()
response = await api_payment( response = await client.get(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"] f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
) )
assert not response["paid"] payment_status = response.json()
assert not payment_status["paid"]
async def listen(): async def listen():
found_checking_id = False found_checking_id = False
@ -317,14 +303,17 @@ async def test_receive_real_invoice_set_pending_and_check_state(
return return
assert found_checking_id assert found_checking_id
task = asyncio.create_task(listen()) async def pay():
await asyncio.sleep(1) await asyncio.sleep(3)
pay_real_invoice(invoice["payment_request"]) pay_real_invoice(invoice["payment_request"])
await asyncio.wait_for(task, timeout=10)
response = await api_payment( await asyncio.gather(listen(), pay())
invoice["payment_hash"], inkey_headers_from["X-Api-Key"] await asyncio.sleep(3)
response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
) )
assert response["paid"] payment_status = response.json()
assert payment_status["paid"]
# get the incoming payment from the db # get the incoming payment from the db
payment = await get_standalone_payment(invoice["payment_hash"], incoming=True) payment = await get_standalone_payment(invoice["payment_hash"], incoming=True)
@ -332,32 +321,15 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert payment.pending is False assert payment.pending is False
# set the incoming invoice to pending # set the incoming invoice to pending
await update_payment_details(payment.checking_id, pending=True) await update_payment_details(payment.checking_id, status=PaymentState.PENDING)
payment_pending = await get_standalone_payment( payment_pending = await get_standalone_payment(
invoice["payment_hash"], incoming=True invoice["payment_hash"], incoming=True
) )
assert payment_pending assert payment_pending
assert payment_pending.pending is True assert payment_pending.pending is True
assert payment_pending.success is False
# check the incoming payment status assert payment_pending.failed is False
await payment.check_status()
payment_not_pending = await get_standalone_payment(
invoice["payment_hash"], incoming=True
)
assert payment_not_pending
assert payment_not_pending.pending is False
# verify we get the same result if we use the checking_id to look up the payment
payment_by_checking_id = await get_standalone_payment(
payment_not_pending.checking_id, incoming=True
)
assert payment_by_checking_id
assert payment_by_checking_id.pending is False
assert payment_by_checking_id.bolt11 == payment_not_pending.bolt11
assert payment_by_checking_id.payment_hash == payment_not_pending.payment_hash
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -55,8 +55,8 @@ cursor.execute(
""" """
INSERT INTO apipayments INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage, (wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra, webhook, expiry) amount, status, memo, fee, extra, webhook, expiry, pending)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
wallet_id, wallet_id,
@ -65,12 +65,13 @@ cursor.execute(
"test_admin_internal", "test_admin_internal",
None, None,
amount * 1000, amount * 1000,
False, "success",
"test_admin_internal", "test_admin_internal",
0, 0,
None, None,
"", "",
expiration_date, expiration_date,
False, # TODO: remove this in next release
), ),
) )