mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-03-15 12:20:21 +01:00
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:
parent
b14d36a0aa
commit
8f761dfd0f
14 changed files with 301 additions and 258 deletions
|
@ -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")
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue