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 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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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) {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue