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 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.views.extension_api import (
api_install_extension,
@ -216,10 +216,12 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_revert_payment(checking_id: str, pending: bool = True):
"""Mark wallet as deleted"""
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
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")

View file

@ -8,6 +8,7 @@ import shortuuid
from passlib.context import CryptContext
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.extension_manager import (
InstallableExtension,
@ -738,7 +739,7 @@ async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: in
rows = await db.fetchall(
f"""
SELECT * FROM apipayments
WHERE pending = false
WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE ?
AND extra LIKE ?
ORDER BY time DESC LIMIT {limit}
@ -782,9 +783,11 @@ async def get_payments_paginated(
if complete and pending:
pass
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:
clause.append("pending = true")
clause.append(f"status = '{PaymentState.PENDING}'")
else:
pass
@ -857,7 +860,7 @@ async def delete_expired_invoices(
await (conn or db).execute(
f"""
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)}
"""
)
@ -865,7 +868,7 @@ async def delete_expired_invoices(
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE pending = true AND amount > 0
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_now}
"""
)
@ -884,9 +887,9 @@ async def create_payment(
amount: int,
memo: str,
fee: int = 0,
status: PaymentState = PaymentState.PENDING,
preimage: Optional[str] = None,
expiry: Optional[datetime.datetime] = None,
pending: bool = True,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
conn: Optional[Connection] = None,
@ -900,8 +903,8 @@ async def create_payment(
"""
INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra, webhook, expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
amount, status, memo, fee, extra, webhook, expiry, pending)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wallet_id,
@ -910,7 +913,7 @@ async def create_payment(
payment_hash,
preimage,
amount,
pending,
status.value,
memo,
fee,
(
@ -920,6 +923,7 @@ async def create_payment(
),
webhook,
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(
checking_id: str, pending: bool, conn: Optional[Connection] = None
checking_id: str, status: PaymentState, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
(pending, checking_id),
"UPDATE apipayments SET status = ? WHERE checking_id = ?",
(status.value, checking_id),
)
async def update_payment_details(
checking_id: str,
pending: Optional[bool] = None,
status: Optional[PaymentState] = None,
fee: Optional[int] = None,
preimage: 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:
set_clause.append("checking_id = ?")
set_variables.append(new_checking_id)
if pending is not None:
set_clause.append("pending = ?")
set_variables.append(pending)
if status is not None:
set_clause.append("status = ?")
set_variables.append(status.value)
if fee is not None:
set_clause.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"]
sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00",
@ -1025,7 +1019,7 @@ async def get_payments_history(
) -> List[PaymentHistoryPoint]:
if not filters:
filters = Filters()
where = ["(pending = False OR amount < 0)"]
where = [f"(status = '{PaymentState.SUCCESS}' OR amount < 0)"]
values = []
if wallet_id:
where.append("wallet = ?")
@ -1090,9 +1084,9 @@ async def check_internal(
otherwise None
"""
row = await (conn or db).fetchone(
"""
f"""
SELECT checking_id FROM apipayments
WHERE hash = ? AND pending AND amount > 0
WHERE hash = ? AND status = '{PaymentState.PENDING}' AND amount > 0
""",
(payment_hash,),
)
@ -1111,15 +1105,14 @@ async def check_internal_pending(
"""
row = await (conn or db).fetchone(
"""
SELECT pending FROM apipayments
SELECT status FROM apipayments
WHERE hash = ? AND amount > 0
""",
(payment_hash,),
)
if not row:
return True
else:
return row["pending"]
return row["status"] == PaymentState.PENDING.value
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.
"""
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 fastapi import Query
from loguru import logger
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.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentStatus,
)
class BaseWallet(BaseModel):
@ -199,9 +201,20 @@ class LoginUsernamePassword(BaseModel):
password: str
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class Payment(FromRowModel):
checking_id: str
status: str
# TODO should be removed in the future, backward compatibility
pending: bool
checking_id: str
amount: int
fee: int
memo: Optional[str]
@ -215,6 +228,14 @@ class Payment(FromRowModel):
webhook: Optional[str]
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
def from_row(cls, row: Row):
return cls(
@ -223,7 +244,9 @@ class Payment(FromRowModel):
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
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"],
fee=row["fee"],
memo=row["memo"],
@ -264,80 +287,16 @@ class Payment(FromRowModel):
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_")
async def update_status(
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:
async def check_status(self) -> PaymentStatus:
if self.is_uncheckable:
return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
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
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):
__search_fields__ = ["memo", "amount"]

View file

@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
@ -50,7 +51,6 @@ from .crud import (
create_admin_settings,
create_payment,
create_wallet,
delete_wallet_payment,
get_account,
get_account_by_email,
get_account_by_username,
@ -67,7 +67,7 @@ from .crud import (
update_user_extension,
)
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):
@ -283,32 +283,19 @@ async def pay_invoice(
new_payment = await create_payment(
checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat),
pending=False,
status=PaymentState.SUCCESS,
conn=conn,
**payment_kwargs,
)
else:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=False
new_payment = await _create_external_payment(
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
wallet = await get_wallet(wallet_id, conn=conn)
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)
if extra and "tag" in extra:
@ -325,7 +312,9 @@ async def pay_invoice(
# the payer has enough to deduct from
async with db.connect() as conn:
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)
@ -350,15 +339,18 @@ async def pay_invoice(
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
logger.debug(f"backend: pay_invoice response {payment}")
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
pending=payment.ok is not True,
status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
@ -376,10 +368,13 @@ async def pay_invoice(
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.warning("backend sent payment failure")
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
await update_payment_status(
checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError(
f"Payment failed: {payment.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,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
pending=False,
status=PaymentState.SUCCESS,
)
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(
wallet: Wallet,
fee_reserve_total_msat: int,
@ -617,12 +663,11 @@ async def check_transaction_status(
)
if not payment:
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)
status: PaymentStatus = await payment.check_status()
return status
return await payment.check_status()
# 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:
checking_id = await check_internal(payment_hash, conn=conn)
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
from lnbits.tasks import internal_invoice_queue
@ -856,3 +903,23 @@ async def get_balance_delta() -> BalanceDelta:
lnbits_balance_msats=lnbits_balance,
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_standalone_payment,
get_wallet_for_key,
update_pending_payments,
)
from ..services import (
check_transaction_status,
create_invoice,
fee_reserve_total,
pay_invoice,
update_pending_payments,
)
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(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
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 payment.success:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
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:
return {
"paid": not payment.pending,
"paid": payment.success,
"status": f"{status!s}",
"preimage": payment.preimage,
"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)

View file

@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
raise HTTPException(
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"}
try:

File diff suppressed because one or more lines are too long

View file

@ -247,7 +247,7 @@ window.LNbits = {
payment: function (data) {
obj = {
checking_id: data.checking_id,
pending: data.pending,
status: data.status,
amount: data.amount,
fee: data.fee,
memo: data.memo,
@ -280,7 +280,9 @@ window.LNbits = {
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = 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()
return obj
}

View file

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

View file

@ -11,11 +11,13 @@ from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_expired_invoices,
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment_details,
update_payment_status,
)
from lnbits.core.models import PaymentState
from lnbits.settings import settings
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
checked only once, outgoing pending payments will be checked regularly.
"""
outgoing = True
incoming = True
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()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
outgoing=outgoing,
incoming=incoming,
exclude_uncheckable=True,
)
for payment in pending_payments:
await payment.check_status()
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {len(pending_payments)} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
# we delete expired invoices once upon the first pending check
if incoming:
logger.debug("Task: deleting all expired invoices")
start_time = time.time()
await delete_expired_invoices()
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
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(
"Task: expired invoice deletion finished (took"
f" {time.time() - start_time:0.3f} s)"
f"Task: pending check finished for {count} payments"
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
@ -183,7 +181,13 @@ async def invoice_callback_dispatcher(checking_id: str):
logger.trace(
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():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
@ -204,8 +208,11 @@ async def send_push_notification(subscription, title, body, url=""):
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
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
await delete_webpush_subscriptions(subscription.endpoint)
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,
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.views.payment_api import api_payments_create_invoice
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()
)
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()}
return fake_data, params

View file

@ -5,9 +5,8 @@ import pytest
from lnbits import bolt11
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.views.payment_api import api_payment
from lnbits.wallets import get_funding_source
from ..helpers import is_fake, is_regtest
@ -88,11 +87,12 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
return
assert found_checking_id
task = asyncio.create_task(listen())
await asyncio.sleep(1)
pay_real_invoice(invoice["payment_request"])
await asyncio.wait_for(task, timeout=10)
async def pay():
await asyncio.sleep(3)
pay_real_invoice(invoice["payment_request"])
await asyncio.gather(listen(), pay())
await asyncio.sleep(3)
response = await client.get(
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
# check the payment status
response = await api_payment(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
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"]
# make sure that the backend also thinks it's paid
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
payment = await get_standalone_payment(invoice["payment_hash"])
assert payment
assert payment.success
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.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)
# 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)
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
@ -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)
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()
assert not status.paid
# 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
assert status.failed
@pytest.mark.asyncio
@ -304,10 +289,11 @@ async def test_receive_real_invoice_set_pending_and_check_state(
)
assert response.status_code < 300
invoice = response.json()
response = await api_payment(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
response = await client.get(
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():
found_checking_id = False
@ -317,14 +303,17 @@ async def test_receive_real_invoice_set_pending_and_check_state(
return
assert found_checking_id
task = asyncio.create_task(listen())
await asyncio.sleep(1)
pay_real_invoice(invoice["payment_request"])
await asyncio.wait_for(task, timeout=10)
response = await api_payment(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
async def pay():
await asyncio.sleep(3)
pay_real_invoice(invoice["payment_request"])
await asyncio.gather(listen(), pay())
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
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
# 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(
invoice["payment_hash"], incoming=True
)
assert payment_pending
assert payment_pending.pending is True
# check the incoming payment status
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
assert payment_pending.success is False
assert payment_pending.failed is False
@pytest.mark.asyncio

View file

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