[feat] Add stripe payments (#3184)

This commit is contained in:
Vlad Stan 2025-06-30 13:13:13 +03:00 committed by dni ⚡
parent 5c9511ccfe
commit 695e9b6471
No known key found for this signature in database
GPG key ID: D1F416F29AD26E87
51 changed files with 2014 additions and 175 deletions

View file

@ -5,7 +5,9 @@ from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router from .views.generic import generic_router
@ -34,10 +36,12 @@ def init_core_routers(app: FastAPI):
app.include_router(wallet_router) app.include_router(wallet_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(websocket_router) app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router) app.include_router(tinyurl_router)
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(fiat_router)
__all__ = ["core_app", "core_app_extra", "db"] __all__ = ["core_app", "core_app_extra", "db"]

View file

@ -195,6 +195,7 @@ async def get_user_from_account(
wallets=wallets, wallets=wallets,
admin=account.is_admin, admin=account.is_admin,
super_user=account.is_super_user, super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None, has_password=account.password_hash is not None,
) )

View file

@ -719,3 +719,7 @@ async def m032_add_external_id_to_accounts(db: Connection):
Used for external account linking. Used for external account linking.
""" """
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT") await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")

View file

@ -8,6 +8,13 @@ from fastapi import Query
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
@ -60,6 +67,8 @@ class Payment(BaseModel):
amount: int amount: int
fee: int fee: int
bolt11: str bolt11: str
# payment_request: str | None
fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: str | None = None memo: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
@ -107,14 +116,23 @@ class Payment(BaseModel):
@property @property
def is_internal(self) -> bool: def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") return self.checking_id.startswith("internal_") or self.checking_id.startswith(
"fiat_"
)
async def check_status(self) -> PaymentStatus: async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if self.is_internal: if self.is_internal:
if self.success: if self.success:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if self.failed: if self.failed:
return PaymentFailedStatus() return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
if self.is_out: if self.is_out:
@ -123,6 +141,39 @@ class Payment(BaseModel):
status = await funding_source.get_invoice_status(self.checking_id) status = await funding_source.get_invoice_status(self.checking_id)
return status return status
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not self.is_internal:
return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"] __search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
@ -206,6 +257,7 @@ class CreateInvoice(BaseModel):
webhook: str | None = None webhook: str | None = None
bolt11: str | None = None bolt11: str | None = None
lnurl_callback: str | None = None lnurl_callback: str | None = None
fiat_provider: str | None = None
@validator("unit") @validator("unit")
@classmethod @classmethod

View file

@ -110,11 +110,13 @@ class Account(BaseModel):
is_super_user: bool = Field(default=False, no_database=True) is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True) is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id) self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
def hash_password(self, password: str) -> str: def hash_password(self, password: str) -> str:
"""sets and returns the hashed password""" """sets and returns the hashed password"""
@ -191,6 +193,7 @@ class User(BaseModel):
wallets: list[Wallet] = [] wallets: list[Wallet] = []
admin: bool = False admin: bool = False
super_user: bool = False super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False has_password: bool = False
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()

View file

@ -8,11 +8,15 @@ from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
check_transaction_status, check_transaction_status,
check_wallet_limits, check_wallet_limits,
create_fiat_invoice,
create_invoice, create_invoice,
create_wallet_invoice,
fee_reserve, fee_reserve,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats,
pay_invoice, pay_invoice,
service_fee, service_fee,
update_pending_payment,
update_pending_payments, update_pending_payments,
update_wallet_balance, update_wallet_balance,
) )
@ -44,10 +48,14 @@ __all__ = [
"check_transaction_status", "check_transaction_status",
"check_wallet_limits", "check_wallet_limits",
"create_invoice", "create_invoice",
"create_wallet_invoice",
"create_fiat_invoice",
"fee_reserve", "fee_reserve",
"fee_reserve_total", "fee_reserve_total",
"get_payments_daily_stats",
"pay_invoice", "pay_invoice",
"service_fee", "service_fee",
"update_pending_payment",
"update_pending_payments", "update_pending_payments",
"update_wallet_balance", "update_wallet_balance",
# settings # settings

View file

@ -0,0 +1,92 @@
import hashlib
import hmac
import time
from typing import Optional
from loguru import logger
from lnbits.core.crud.payments import get_standalone_payment
from lnbits.core.models.misc import SimpleStatus
from lnbits.fiat import get_fiat_provider
async def handle_stripe_event(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
def check_stripe_signature(
payload: bytes,
sig_header: Optional[str],
secret: Optional[str],
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Stripe-Signature header is missing.")
raise ValueError("Stripe-Signature header is missing.")
if not secret:
logger.warning("Stripe webhook signing secret is not set.")
raise ValueError("Stripe webhook cannot be verified.")
# Split the Stripe-Signature header
items = dict(i.split("=") for i in sig_header.split(","))
timestamp = int(items["t"])
signature = items["v1"]
# Check timestamp tolerance
if abs(time.time() - timestamp) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
logger.debug(
f"Current time: {time.time()}, "
f"Timestamp: {timestamp}, "
f"Tolerance: {tolerance_seconds} seconds"
)
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = f"{timestamp}.{payload.decode()}"
# Compute HMAC SHA256 using the webhook secret
computed_signature = hmac.new(
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
).hexdigest()
# Compare signatures using constant time comparison
if hmac.compare_digest(computed_signature, signature) is not True:
logger.warning("Stripe signature verification failed.")
raise ValueError("Stripe signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
success=False,
message=f"Cconnection test failed: {status.error_message}",
)
return SimpleStatus(
success=True,
message="Connection test successful." f" Balance: {status.balance}.",
)

View file

@ -1,8 +1,10 @@
import asyncio import asyncio
import json
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode from bolt11 import encode as bolt11_encode
@ -11,11 +13,13 @@ from loguru import logger
from lnbits.core.crud.payments import get_daily_stats from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_task
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
@ -94,6 +98,122 @@ async def pay_invoice(
return payment return payment
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
):
fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name:
raise ValueError("Fiat provider is required for fiat invoices.")
if not settings.is_fiat_provider_enabled(fiat_provider_name):
raise ValueError(
f"Fiat provider '{fiat_provider_name}' is not enabled.",
)
if invoice_data.unit == "sat":
raise ValueError("Fiat provider cannot be used with satoshis.")
amount_sat = await fiat_amount_as_satoshis(invoice_data.amount, invoice_data.unit)
await _check_fiat_invoice_limits(amount_sat, fiat_provider_name, conn)
invoice_data.internal = True # use FakeWallet for fiat invoices
if not invoice_data.memo:
invoice_data.memo = settings.lnbits_site_title + f" ({fiat_provider_name})"
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
fiat_provider = await get_fiat_provider(fiat_provider_name)
fiat_invoice = await fiat_provider.create_invoice(
amount=invoice_data.amount,
payment_hash=internal_payment.payment_hash,
currency=invoice_data.unit,
memo=invoice_data.memo,
)
if fiat_invoice.failed:
logger.warning(fiat_invoice.error_message)
internal_payment.status = PaymentState.FAILED
await update_payment(internal_payment, conn=conn)
raise ValueError(
f"Cannot create payment request for '{fiat_provider_name}'.",
)
internal_payment.fee = -abs(
service_fee_fiat(internal_payment.msat, fiat_provider_name)
)
internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# todo: move to payent
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = (
f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
)
await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment.checking_id = new_checking_id
return internal_payment
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError as exc:
raise ValueError(
"'description_hash' must be a valid hex string"
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise ValueError(
"'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
payment = await create_invoice(
wallet_id=wallet_id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(data.lnurl_callback)
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
return payment
async def create_invoice( async def create_invoice(
*, *,
wallet_id: str, wallet_id: str,
@ -226,6 +346,26 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
return 0 return 0
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
"""
Calculate the service fee for a fiat provider based on the amount in msat.
Return the fee in msat.
"""
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return 0
amount_msat = abs(amount_msat)
fee_max = limits.service_max_fee_sats * 1000
if not limits.service_fee_wallet_id:
return 0
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
async def update_wallet_balance( async def update_wallet_balance(
wallet: Wallet, wallet: Wallet,
amount: int, amount: int,
@ -449,6 +589,20 @@ async def get_payments_daily_stats(
return data return data
async def handle_fiat_payment_confirmation(
payment: Payment, conn: Optional[Connection] = None
):
try:
await _credit_fiat_service_fee_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
try:
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
async def _pay_invoice( async def _pay_invoice(
wallet_id: str, wallet_id: str,
create_payment_model: CreatePayment, create_payment_model: CreatePayment,
@ -573,6 +727,8 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False) service_fee_msat = service_fee(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task( task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat) _fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
) )
@ -728,3 +884,126 @@ async def _credit_service_fee_wallet(
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
) )
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
if payment.fee == 0:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_fee_wallet_id:
return
memo = (
f"Service fee for fiat payment of "
f"{abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_fee_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(payment.fee),
memo=memo,
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def _debit_fiat_service_faucet_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_faucet_wallet_id:
return
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
)
memo = (
f"Faucet payment of {abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_faucet_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=-abs(payment.amount),
memo=memo,
extra=payment.extra,
)
await create_payment(
checking_id=f"internal_fiat_{fiat_provider_name}_"
f"faucet_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def _check_fiat_invoice_limits(
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
):
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' does not have limits configured.",
)
min_amount_sat = limits.service_min_amount_sats
if min_amount_sat and (amount_sat < min_amount_sat):
raise ValueError(
f"Minimum amount is {min_amount_sat} " f"sats for '{fiat_provider_name}'.",
)
max_amount_sats = limits.service_max_amount_sats
if max_amount_sats and (amount_sat > max_amount_sats):
raise ValueError(
f"Maximum amount is {max_amount_sats} " f"sats for '{fiat_provider_name}'.",
)
if limits.service_max_fee_sats > 0 or limits.service_fee_percent > 0:
if not limits.service_fee_wallet_id:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet missing.",
)
fees_wallet = await get_wallet(limits.service_fee_wallet_id, conn=conn)
if not fees_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet not found.",
)
if limits.service_faucet_wallet_id:
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found.",
)
if faucet_wallet.balance < amount_sat:
raise ValueError(
f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.",
)

View file

@ -0,0 +1,287 @@
<q-tab-panel name="fiat_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('fiat_providers')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col">
<q-list bordered class="rounded-borders">
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Stripe </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.stripe_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.stripe_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.stripe_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('stripe')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_stripe_description')"></span>
</q-card-section>
<q-card-section>
<q-input
filled
class="q-mt-md"
type="text"
disable
v-model="formData.stripe_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('webhook_url_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')"
:hint="$t('signing_secret_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>checkout.session.async_payment_failed</code></li>
<li><code>checkout.session.async_payment_succeeded</code></li>
<li><code>checkout.session.completed</code></li>
<li><code>checkout.session.expired</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_fee_percent"
@update:model-value="touchSettings()"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_fee_sats"
@update:model-value="touchSettings()"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.stripe_limits.service_fee_wallet_id"
@update:model-value="touchSettings()"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_min_amount_sats"
@update:model-value="touchSettings()"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_amount_sats"
@update:model-value="touchSettings()"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.stripe_limits.service_faucet_wallet_id"
@update:model-value="touchSettings()"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddStripeUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
>
<q-btn
@click="addStripeAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.stripe_limits.allowed_users"
@update:model-value="touchSettings()"
:key="user"
removable
@remove="removeStripeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator />
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">Disabled</div>
</q-item-section>
</template>
<q-card>
<q-card-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</div>
</q-tab-panel>

View file

@ -115,6 +115,13 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm" ><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip ><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab> ></q-tab>
<q-tab
name="fiat_providers"
icon="credit_score"
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('fiat_providers')"></span></q-tooltip
></q-tab>
<q-tab <q-tab
name="users" name="users"
icon="group" icon="group"
@ -184,6 +191,7 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% include "admin/_tab_funding.html" %} {% include {% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" "admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %}{% include %} {% include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include "admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include "admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html" "admin/_tab_security.html" %} {% include "admin/_tab_theme.html"

View file

@ -211,7 +211,12 @@
</q-card> </q-card>
<div id="hiddenQrCodeContainer" style="display: none"> <div id="hiddenQrCodeContainer" style="display: none">
<lnbits-qrcode <lnbits-qrcode
:value="'lightning:' + this.receive.paymentReq" v-if="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq"
></lnbits-qrcode>
<lnbits-qrcode
v-else
:value="'lightning:' + (this.receive.paymentReq || '').toUpperCase()"
></lnbits-qrcode> ></lnbits-qrcode>
</div> </div>
</div> </div>
@ -619,6 +624,8 @@
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
></q-input> ></q-input>
{% else %} {% else %}
<div class="row">
<div class="col-10">
<q-select <q-select
filled filled
dense dense
@ -627,6 +634,20 @@
:label="$t('unit')" :label="$t('unit')"
:options="receive.units" :options="receive.units"
></q-select> ></q-select>
</div>
<div class="col-2">
<q-btn
v-if="g.fiatTracking"
@click="swapBalancePriority"
class="float-right"
color="primary"
flat
dense
icon="swap_vert"
></q-btn>
</div>
</div>
<q-input <q-input
ref="setAmount" ref="setAmount"
filled filled
@ -644,9 +665,59 @@
<q-input <q-input
filled filled
dense dense
type="textarea"
rows="2"
v-model.trim="receive.data.memo" v-model.trim="receive.data.memo"
:label="$t('memo')" :label="$t('memo')"
></q-input> ></q-input>
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
<q-list bordered dense class="rounded-borders">
<q-item-label dense header>
<span v-text="$t('select_payment_provider')"></span>
</q-item-label>
<q-separator></q-separator>
<q-item
:active="!receive.fiatProvider"
@click="receive.fiatProvider = ''"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar square>
<img
:src="'{{ static_url_for('static', 'images/logos/lnbits.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section>
<span
v-text="$t('pay_with', {provider: 'Lightning Network'})"
></span>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
:active="receive.fiatProvider === 'stripe'"
@click="receive.fiatProvider = 'stripe'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Stripe'})"></span>
</q-item-section>
</q-item>
</q-list>
</div>
<div v-if="receive.status == 'pending'" class="row q-mt-lg"> <div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -680,7 +751,14 @@
class="q-pa-lg q-pt-xl lnbits__dialog-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">
<a :href="'lightning:' + receive.paymentReq"> <a
v-if="receive.fiatPaymentReq"
:href="receive.fiatPaymentReq"
target="_blank"
>
<div v-html="invoiceQrCode"></div>
</a>
<a v-else :href="'lightning:' + receive.paymentReq">
<div v-html="invoiceQrCode"></div> <div v-html="invoiceQrCode"></div>
</a> </a>
</div> </div>
@ -691,6 +769,7 @@
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm"> <h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span> <span v-text="formattedSatAmount"></span>
</h5> </h5>
<div v-if="!receive.fiatPaymentReq">
<q-chip v-if="hasNfc" outline square color="positive"> <q-chip v-if="hasNfc" outline square color="positive">
<q-avatar <q-avatar
icon="nfc" icon="nfc"
@ -705,11 +784,12 @@
v-text="$t('nfc_not_supported')" v-text="$t('nfc_not_supported')"
></span> ></span>
</div> </div>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText(receive.paymentReq)" @click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
:label="$t('copy_invoice')" :label="$t('copy_invoice')"
></q-btn> ></q-btn>
<q-btn <q-btn

View file

@ -0,0 +1,35 @@
from fastapi import APIRouter, Request
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import (
check_stripe_signature,
handle_stripe_event,
)
from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@callback_router.post("/{provider_name}")
async def api_generic_webhook_handler(
provider_name: str, request: Request
) -> SimpleStatus:
if provider_name.lower() == "stripe":
payload = await request.body()
sig_header = request.headers.get("Stripe-Signature")
check_stripe_signature(
payload, sig_header, settings.stripe_webhook_signing_secret
)
event = await request.json()
await handle_stripe_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
)

View file

@ -0,0 +1,18 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
@fiat_router.put(
"/check/{provider}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)

View file

@ -34,13 +34,8 @@ from lnbits.core.models import (
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentWalletStats, PaymentWalletStats,
Wallet,
) )
from lnbits.core.models.users import User from lnbits.core.models.users import User
from lnbits.core.services.payments import (
get_payments_daily_stats,
update_pending_payment,
)
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
@ -67,9 +62,12 @@ from ..crud import (
get_wallet_for_key, get_wallet_for_key,
) )
from ..services import ( from ..services import (
create_invoice, create_fiat_invoice,
create_wallet_invoice,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats,
pay_invoice, pay_invoice,
update_pending_payment,
update_pending_payments, update_pending_payments,
) )
@ -198,69 +196,6 @@ async def api_payments_paginated(
return page return page
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
payment = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(data.lnurl_callback)
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
return payment
@payment_router.get( @payment_router.get(
"/all/paginated", "/all/paginated",
name="Payment List", name="Payment List",
@ -308,6 +243,7 @@ async def api_payments_create(
invoice_data: CreateInvoice, invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment: ) -> Payment:
wallet_id = wallet.wallet.id
if invoice_data.out is True and wallet.key_type == KeyType.admin: if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11: if not invoice_data.bolt11:
raise HTTPException( raise HTTPException(
@ -315,21 +251,24 @@ async def api_payments_create(
detail="Missing BOLT11 invoice", detail="Missing BOLT11 invoice",
) )
payment = await pay_invoice( payment = await pay_invoice(
wallet_id=wallet.wallet.id, wallet_id=wallet_id,
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
) )
return payment return payment
elif not invoice_data.out: if invoice_data.out:
# invoice key
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, status_code=HTTPStatus.FORBIDDEN,
detail="Invoice (or Admin) key required.", detail="Invoice (or Admin) key required.",
) )
# If the payment is not outgoing, we can create a new invoice.
if invoice_data.fiat_provider:
return await create_fiat_invoice(wallet_id, invoice_data)
return await create_wallet_invoice(wallet_id, invoice_data)
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:

View file

@ -26,6 +26,10 @@ class InvoiceError(Exception):
self.status = status self.status = status
class UnsupportedError(Exception):
pass
def render_html_error(request: Request, exc: Exception) -> Optional[Response]: def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request # Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response # not fail proof, but everything else get's a JSON response

46
lnbits/fiat/__init__.py Normal file
View file

@ -0,0 +1,46 @@
from __future__ import annotations
import importlib
from enum import Enum
from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum):
stripe = "StripeWallet"
async def get_fiat_provider(name: str) -> FiatProvider:
if name not in FiatProviderType.__members__:
raise ValueError(f"Fiat provider '{name}' is not supported.")
fiat_provider = fiat_providers.get(name)
if fiat_provider:
status = await fiat_provider.status(only_check_settings=True)
if status.error_message:
await fiat_provider.cleanup()
del fiat_providers[name]
else:
return fiat_provider
fiat_providers[name] = _init_fiat_provider(FiatProviderType[name])
return fiat_providers[name]
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider:
if not settings.is_fiat_provider_enabled(fiat_provider.name):
raise ValueError(f"Fiat provider '{fiat_provider.name}' not enabled.")
provider_constructor = getattr(fiat_module, fiat_provider.value)
return provider_constructor()
fiat_providers: dict[str, FiatProvider] = {}
__all__ = [
"StripeWallet",
]

134
lnbits/fiat/base.py Normal file
View file

@ -0,0 +1,134 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, AsyncGenerator, Coroutine, NamedTuple
if TYPE_CHECKING:
pass
class FiatStatusResponse(NamedTuple):
error_message: str | None = None
balance: float = 0
class FiatInvoiceResponse(NamedTuple):
ok: bool
checking_id: str | None = None # payment_hash, rpc_id
payment_request: str | None = None
error_message: str | None = None
@property
def success(self) -> bool:
return self.ok is True
@property
def pending(self) -> bool:
return self.ok is None
@property
def failed(self) -> bool:
return self.ok is False
class FiatPaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded
ok: bool | None = None
checking_id: str | None = None # payment_hash, rcp_id
fee: float | None = None
error_message: str | None = None
@property
def success(self) -> bool:
return self.ok is True
@property
def pending(self) -> bool:
return self.ok is None
@property
def failed(self) -> bool:
return self.ok is False
class FiatPaymentStatus(NamedTuple):
paid: bool | None = None
fee: float | None = None # todo: what fee is this?
@property
def success(self) -> bool:
return self.paid is True
@property
def pending(self) -> bool:
return self.paid is not True
@property
def failed(self) -> bool:
return self.paid is False
def __str__(self) -> str:
if self.success:
return "success"
if self.failed:
return "failed"
return "pending"
class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True
class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False
class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None
class FiatProvider(ABC):
@abstractmethod
async def cleanup(self):
pass
@abstractmethod
def status(
self, only_check_settings: bool | None = False
) -> Coroutine[None, None, FiatStatusResponse]:
pass
@abstractmethod
def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
**kwargs,
) -> Coroutine[None, None, FiatInvoiceResponse]:
pass
@abstractmethod
def pay_invoice(
self,
payment_request: str,
) -> Coroutine[None, None, FiatPaymentResponse]:
pass
@abstractmethod
def get_invoice_status(
self, checking_id: str
) -> Coroutine[None, None, FiatPaymentStatus]:
pass
@abstractmethod
def get_payment_status(
self, checking_id: str
) -> Coroutine[None, None, FiatPaymentStatus]:
pass
async def paid_invoices_stream(
self,
) -> AsyncGenerator[str, None]:
yield ""

173
lnbits/fiat/stripe.py Normal file
View file

@ -0,0 +1,173 @@
import asyncio
import json
from datetime import datetime, timedelta, timezone
from typing import AsyncGenerator, Optional
from urllib.parse import urlencode
import httpx
from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
)
class StripeWallet(FiatProvider):
"""https://docs.stripe.com/api"""
def __init__(self):
logger.debug("Initializing StripeWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.stripe_api_endpoint:
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
if not settings.stripe_api_secret_key:
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("StripeWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing stripe wallet connection: {e}")
async def status(
self, only_check_settings: Optional[bool] = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(url="/v1/balance", timeout=15)
r.raise_for_status()
data = r.json()
available_balance = data.get("available", [{}])[0].get("amount", 0)
# pending_balance = data.get("pending", {}).get("amount", 0)
return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: Optional[str] = None,
**kwargs,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
form_data = [
("mode", "payment"),
(
"success_url",
settings.stripe_payment_success_url or "https://lnbits.com",
),
("metadata[payment_hash]", payment_hash),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
("line_items[0][price_data][unit_amount]", amount_cents),
("line_items[0][quantity]", "1"),
]
encoded_data = urlencode(form_data)
try:
headers = self.headers.copy()
headers["Content-Type"] = "application/x-www-form-urlencoded"
r = await self.client.post(
url="/v1/checkout/sessions", headers=headers, content=encoded_data
)
r.raise_for_status()
data = r.json()
session_id = data.get("id")
if not session_id:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing session id'"
)
payment_request = data.get("url")
if not payment_request:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing payment URL'"
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=payment_request
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Stripe does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
r = await self.client.get(
url=f"/v1/checkout/sessions/{checking_id}",
)
r.raise_for_status()
data = r.json()
payment_status = data.get("payment_status")
if not payment_status:
return FiatPaymentPendingStatus()
if payment_status == "paid":
# todo: handle fee
return FiatPaymentSuccessStatus()
expires_at = data.get("expires_at")
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and expires_at < _24_hours_ago.timestamp():
# be defensive: add a 24 hour buffer
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
except Exception as exc:
logger.debug(f"Error getting invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Stripe does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Stripe does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _settings_connection_fields(self) -> str:
return "-".join(
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
)

View file

@ -363,3 +363,14 @@ def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
# Prevent filename with subdirectories # Prevent filename with subdirectories
file_path = image_folder / filename.split("/")[-1] file_path = image_folder / filename.split("/")[-1]
return file_path.resolve() return file_path.resolve()
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
if add_proto:
if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
return endpoint
endpoint = (
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
)
return endpoint

View file

@ -565,6 +565,34 @@ class StrikeFundingSource(LNbitsSettings):
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY") strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
class FiatProviderLimits(BaseModel):
# empty list means all users are allowed to receive payments via Stripe
allowed_users: list[str] = Field(default=[])
service_max_fee_sats: int = Field(default=0)
service_fee_percent: float = Field(default=0)
service_fee_wallet_id: str | None = Field(default=None)
service_min_amount_sats: int = Field(default=0)
service_max_amount_sats: int = Field(default=0)
service_faucet_wallet_id: str | None = Field(default="")
class StripeFiatProvider(LNbitsSettings):
stripe_enabled: bool = Field(default=False)
stripe_api_endpoint: str = Field(default="https://api.stripe.com")
stripe_api_secret_key: str | None = Field(default=None)
stripe_payment_success_url: str = Field(default="https://lnbits.com")
stripe_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/stripe"
)
# Use this secret to verify that events come from Stripe.
stripe_webhook_signing_secret: str | None = Field(default=None)
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@ -597,6 +625,40 @@ class FundingSourcesSettings(
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0) lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
class FiatProvidersSettings(StripeFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
"""
if not provider:
return False
if provider == "stripe":
return self.stripe_enabled
# Add checks for other fiat providers here as needed
return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
"""
Returns a list of fiat payment methods allowed for the user.
"""
allowed_providers = []
if self.stripe_enabled and (
not self.stripe_limits.allowed_users
or user_id in self.stripe_limits.allowed_users
):
allowed_providers.append("stripe")
# Add other fiat providers here as needed
return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
"""
Returns the limits for a specific fiat provider.
"""
return getattr(self, provider_name + "_limits", None)
class WebPushSettings(LNbitsSettings): class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: str | None = Field(default=None) lnbits_webpush_pubkey: str | None = Field(default=None)
lnbits_webpush_privkey: str | None = Field(default=None) lnbits_webpush_privkey: str | None = Field(default=None)
@ -769,6 +831,7 @@ class EditableSettings(
SecuritySettings, SecuritySettings,
NotificationsSettings, NotificationsSettings,
FundingSourcesSettings, FundingSourcesSettings,
FiatProvidersSettings,
LightningSettings, LightningSettings,
WebPushSettings, WebPushSettings,
NodeUISettings, NodeUISettings,

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,7 @@ window.localisation.en = {
active_channels: 'Active Channels', active_channels: 'Active Channels',
connect_peer: 'Connect Peer', connect_peer: 'Connect Peer',
connect: 'Connect', connect: 'Connect',
reconnect: 'Reconnect',
open_channel: 'Open Channel', open_channel: 'Open Channel',
open: 'Open', open: 'Open',
close_channel: 'Close Channel', close_channel: 'Close Channel',
@ -60,6 +61,7 @@ window.localisation.en = {
rename_wallet: 'Rename wallet', rename_wallet: 'Rename wallet',
update_name: 'Update name', update_name: 'Update name',
fiat_tracking: 'Fiat tracking', fiat_tracking: 'Fiat tracking',
fiat_providers: 'Fiat providers',
currency: 'Currency', currency: 'Currency',
update_currency: 'Update currency', update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin', press_to_claim: 'Press to claim bitcoin',
@ -141,6 +143,7 @@ window.localisation.en = {
uninstall: 'Uninstall', uninstall: 'Uninstall',
drop_db: 'Remove Data', drop_db: 'Remove Data',
enable: 'Enable', enable: 'Enable',
enabled: 'Enabled',
pay_to_enable: 'Pay To Enable', pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
disable: 'Disable', disable: 'Disable',
@ -171,12 +174,33 @@ window.localisation.en = {
payment_hash: 'Payment Hash', payment_hash: 'Payment Hash',
fee: 'Fee', fee: 'Fee',
amount: 'Amount', amount: 'Amount',
amount_limits: 'Amount Limits',
amount_sats: 'Amount (sats)', amount_sats: 'Amount (sats)',
faucest_wallet: 'Faucet Wallet',
faucest_wallet_desc_1:
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
faucest_wallet_desc_2:
'This helps monitor all {provider} payments and their status.',
faucest_wallet_desc_3:
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
faucest_wallet_desc_4:
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
faucest_wallet_desc_5:
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
faucest_wallet_id: 'Faucet Wallet ID (optional)',
faucest_wallet_id_hint:
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
tag: 'Tag', tag: 'Tag',
unit: 'Unit', unit: 'Unit',
description: 'Description', description: 'Description',
expiry: 'Expiry', expiry: 'Expiry',
webhook: 'Webhook', webhook: 'Webhook',
webhook_url: 'Webhook URL',
webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
payment_proof: 'Payment Proof', payment_proof: 'Payment Proof',
update: 'Update', update: 'Update',
update_available: 'Update {version} available!', update_available: 'Update {version} available!',
@ -357,6 +381,8 @@ window.localisation.en = {
back: 'Back', back: 'Back',
logout: 'Logout', logout: 'Logout',
look_and_feel: 'Look and Feel', look_and_feel: 'Look and Feel',
endpoint: 'Endpoint',
api: 'API',
api_token: 'API Token', api_token: 'API Token',
api_tokens: 'API Tokens', api_tokens: 'API Tokens',
access_control_list: 'Access Control List', access_control_list: 'Access Control List',
@ -374,6 +400,8 @@ window.localisation.en = {
extension_paid_sats: 'You have already paid {paid_sats} sats.', extension_paid_sats: 'You have already paid {paid_sats} sats.',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
pay_with: 'Pay with {provider}',
select_payment_provider: 'Select payment provider',
wallet_required: 'Wallet *', wallet_required: 'Wallet *',
show_qr: 'Show QR', show_qr: 'Show QR',
retry_install: 'Retry Install', retry_install: 'Retry Install',
@ -386,6 +414,9 @@ window.localisation.en = {
'The {name} extension requires a payment of minimum {amount} sats to enable.', 'The {name} extension requires a payment of minimum {amount} sats to enable.',
hide_empty_wallets: 'Hide empty wallets', hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck', recheck: 'Recheck',
check: 'Check',
check_connection: 'Check Connection',
check_webhook: 'Check Webhook',
contributors: 'Contributors', contributors: 'Contributors',
license: 'License', license: 'License',
reset_key: 'Reset Key', reset_key: 'Reset Key',
@ -492,7 +523,9 @@ window.localisation.en = {
allowed_currencies_hint: 'Limit the number of available fiat currencies', allowed_currencies_hint: 'Limit the number of available fiat currencies',
default_account_currency: 'Default Account Currency', default_account_currency: 'Default Account Currency',
default_account_currency_hint: 'Default currency for accounting', default_account_currency_hint: 'Default currency for accounting',
min_incoming_payment_amount: 'Min Incoming Payment Amount',
min_incoming_payment_amount_desc:
'Minimum amount allowed for generating an invoice',
max_incoming_payment_amount: 'Max Incoming Payment Amount', max_incoming_payment_amount: 'Max Incoming Payment Amount',
max_incoming_payment_amount_desc: max_incoming_payment_amount_desc:
'Maximum amount allowed for generating an invoice', 'Maximum amount allowed for generating an invoice',
@ -554,6 +587,7 @@ window.localisation.en = {
admin_users_label: 'User ID', admin_users_label: 'User ID',
allowed_users: 'Allowed Users', allowed_users: 'Allowed Users',
allowed_users_hint: 'Only these users can use LNbits', allowed_users_hint: 'Only these users can use LNbits',
allowed_users_hint_feature: 'Only these users can use {feature}',
allowed_users_label: 'User ID', allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users', allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page', allow_creation_user_desc: 'Allow creation of new users on the index page',
@ -588,5 +622,12 @@ window.localisation.en = {
view_column: 'View wallets as rows', view_column: 'View wallets as rows',
filter_payments: 'Filter payments', filter_payments: 'Filter payments',
filter_date: 'Filter by date', filter_date: 'Filter by date',
websocket_example: 'Websocket example' websocket_example: 'Websocket example',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -54,6 +54,7 @@ window.AdminPageLogic = {
chartReady: false, chartReady: false,
formAddAdmin: '', formAddAdmin: '',
formAddUser: '', formAddUser: '',
formAddStripeUser: '',
hideInputToggle: true, hideInputToggle: true,
formAddExtensionsManifest: '', formAddExtensionsManifest: '',
nostrNotificationIdentifier: '', nostrNotificationIdentifier: '',
@ -187,6 +188,23 @@ window.AdminPageLogic = {
let allowed_users = this.formData.lnbits_allowed_users let allowed_users = this.formData.lnbits_allowed_users
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user) this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
}, },
addStripeAllowedUser() {
const addUser = this.formAddStripeUser || ''
if (
addUser.length &&
!this.formData.stripe_limits.allowed_users.includes(addUser)
) {
this.formData.stripe_limits.allowed_users = [
...this.formData.stripe_limits.allowed_users,
addUser
]
this.formAddStripeUser = ''
}
},
removeStripeAllowedUser(user) {
this.formData.stripe_limits.allowed_users =
this.formData.stripe_limits.allowed_users.filter(u => u !== user)
},
addIncludePath() { addIncludePath() {
if (!this.formAddIncludePath) { if (!this.formAddIncludePath) {
return return
@ -613,6 +631,20 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}) })
}, },
checkFiatProvider(providerName) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
.then(response => {
response
const data = response.data
Quasar.Notify.create({
type: data.success ? 'positive' : 'warning',
message: data.message,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
downloadBackup() { downloadBackup() {
window.open('/admin/api/v1/backup', '_blank') window.open('/admin/api/v1/backup', '_blank')
}, },

View file

@ -19,14 +19,16 @@ window.LNbits = {
amount, amount,
memo, memo,
unit = 'sat', unit = 'sat',
lnurlCallback = null lnurlCallback = null,
fiatProvider = null
) { ) {
return this.request('post', '/api/v1/payments', wallet.inkey, { return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false, out: false,
amount: amount, amount: amount,
memo: memo, memo: memo,
lnurl_callback: lnurlCallback, lnurl_callback: lnurlCallback,
unit: unit unit: unit,
fiat_provider: fiatProvider
}) })
}, },
payInvoice(wallet, bolt11) { payInvoice(wallet, bolt11) {
@ -197,6 +199,7 @@ window.LNbits = {
email: data.email, email: data.email,
extensions: data.extensions, extensions: data.extensions,
wallets: data.wallets, wallets: data.wallets,
fiat_providers: data.fiat_providers || [],
super_user: data.super_user, super_user: data.super_user,
extra: data.extra ?? {} extra: data.extra ?? {}
} }

View file

@ -35,6 +35,7 @@ window.WalletPageLogic = {
lnurl: null, lnurl: null,
units: ['sat'], units: ['sat'],
unit: 'sat', unit: 'sat',
fiatProvider: '',
data: { data: {
amount: null, amount: null,
memo: '' memo: ''
@ -253,12 +254,15 @@ window.WalletPageLogic = {
this.receive.data.amount, this.receive.data.amount,
this.receive.data.memo, this.receive.data.memo,
this.receive.unit, this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback this.receive.lnurl && this.receive.lnurl.callback,
this.receive.fiatProvider
) )
.then(response => { .then(response => {
this.g.updatePayments = !this.g.updatePayments this.g.updatePayments = !this.g.updatePayments
this.receive.status = 'success' this.receive.status = 'success'
this.receive.paymentReq = response.data.bolt11 this.receive.paymentReq = response.data.bolt11
this.receive.fiatPaymentReq =
response.data.extra?.fiat_payment_request
this.receive.amountMsat = response.data.amount this.receive.amountMsat = response.data.amount
this.receive.paymentHash = response.data.payment_hash this.receive.paymentHash = response.data.payment_hash
if (!this.receive.lnurl) { if (!this.receive.lnurl) {
@ -820,6 +824,9 @@ window.WalletPageLogic = {
}, },
swapBalancePriority() { swapBalancePriority() {
this.isFiatPriority = !this.isFiatPriority this.isFiatPriority = !this.isFiatPriority
this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat'
: 'sat'
this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority) this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority)
}, },
handleFiatTracking() { handleFiatTracking() {

View file

@ -18,6 +18,7 @@ from lnbits.core.crud import (
update_payment, update_payment,
) )
from lnbits.core.models import Payment, PaymentState from lnbits.core.models import Payment, PaymentState
from lnbits.core.services.payments import handle_fiat_payment_confirmation
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
@ -200,13 +201,21 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
invoice_listeners from core and extensions. invoice_listeners from core and extensions.
""" """
payment = await get_standalone_payment(checking_id, incoming=True) payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in: if not payment:
status = await payment.check_status() logger.warning(f"No payment found for '{checking_id}'.")
payment.fee = status.fee_msat or 0 return
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return
status = await payment.check_status(skip_internal_payment_notifications=True)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it # only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment) await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
internal = "internal" if is_internal else "" internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled") logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items(): for name, send_chan in invoice_listeners.items():

View file

@ -998,12 +998,24 @@
v-if="props.row.isIn && props.row.isPending && props.row.bolt11" v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
class="text-center q-my-lg" class="text-center q-my-lg"
> >
<div v-if="props.row.extra.fiat_payment_request">
<a
:href="props.row.extra.fiat_payment_request"
target="_blank"
>
<lnbits-qrcode
:value="props.row.extra.fiat_payment_request"
></lnbits-qrcode>
</a>
</div>
<div v-else>
<a :href="'lightning:' + props.row.bolt11"> <a :href="'lightning:' + props.row.bolt11">
<lnbits-qrcode <lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()" :value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode> ></lnbits-qrcode>
</a> </a>
</div> </div>
</div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<div class="row q-gutter-x-sm"> <div class="row q-gutter-x-sm">
@ -1013,7 +1025,11 @@
" "
outline outline
color="grey" color="grey"
@click="copyText(props.row.bolt11)" @click="
copyText(
props.row.extra.fiat_payment_request || props.row.bolt11
)
"
:label="$t('copy_invoice')" :label="$t('copy_invoice')"
></q-btn> ></q-btn>
<q-btn <q-btn

View file

@ -6,6 +6,7 @@ from typing import AsyncGenerator, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -27,7 +28,7 @@ class AlbyWallet(Wallet):
if not settings.alby_access_token: if not settings.alby_access_token:
raise ValueError("cannot initialize AlbyWallet: missing alby_access_token") raise ValueError("cannot initialize AlbyWallet: missing alby_access_token")
self.endpoint = self.normalize_endpoint(settings.alby_api_endpoint) self.endpoint = normalize_endpoint(settings.alby_api_endpoint)
self.auth = { self.auth = {
"Authorization": "Bearer " + settings.alby_access_token, "Authorization": "Bearer " + settings.alby_access_token,
"User-Agent": settings.user_agent, "User-Agent": settings.user_agent,

View file

@ -150,17 +150,3 @@ class Wallet(ABC):
except Exception as exc: except Exception as exc:
logger.error(f"could not get status of invoice {invoice}: '{exc}' ") logger.error(f"could not get status of invoice {invoice}: '{exc}' ")
await asyncio.sleep(5) await asyncio.sleep(5)
def normalize_endpoint(self, endpoint: str, add_proto=True) -> str:
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
if add_proto:
if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
return endpoint
endpoint = (
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
)
return endpoint
class UnsupportedError(Exception):
pass

View file

@ -10,6 +10,7 @@ from websockets.client import WebSocketClientProtocol, connect
from websockets.typing import Subprotocol from websockets.typing import Subprotocol
from lnbits import bolt11 from lnbits import bolt11
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -34,13 +35,13 @@ class BlinkWallet(Wallet):
if not settings.blink_token: if not settings.blink_token:
raise ValueError("cannot initialize BlinkWallet: missing blink_token") raise ValueError("cannot initialize BlinkWallet: missing blink_token")
self.endpoint = self.normalize_endpoint(settings.blink_api_endpoint) self.endpoint = normalize_endpoint(settings.blink_api_endpoint)
self.auth = { self.auth = {
"X-API-KEY": settings.blink_token, "X-API-KEY": settings.blink_token,
"User-Agent": settings.user_agent, "User-Agent": settings.user_agent,
} }
self.ws_endpoint = self.normalize_endpoint(settings.blink_ws_endpoint) self.ws_endpoint = normalize_endpoint(settings.blink_ws_endpoint)
self.ws_auth = { self.ws_auth = {
"type": "connection_init", "type": "connection_init",
"payload": {"X-API-KEY": settings.blink_token}, "payload": {"X-API-KEY": settings.blink_token},

View file

@ -5,6 +5,7 @@ from bolt11.decode import decode
from grpc.aio import AioRpcError from grpc.aio import AioRpcError
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc
@ -42,7 +43,7 @@ class BoltzWallet(Wallet):
"cannot initialize BoltzWallet: missing boltz_client_wallet" "cannot initialize BoltzWallet: missing boltz_client_wallet"
) )
self.endpoint = self.normalize_endpoint( self.endpoint = normalize_endpoint(
settings.boltz_client_endpoint, add_proto=True settings.boltz_client_endpoint, add_proto=True
) )

View file

@ -1,5 +1,7 @@
import base64 import base64
from lnbits.exceptions import UnsupportedError
try: try:
import breez_sdk # type: ignore import breez_sdk # type: ignore
@ -34,7 +36,6 @@ else:
PaymentStatus, PaymentStatus,
PaymentSuccessStatus, PaymentSuccessStatus,
StatusResponse, StatusResponse,
UnsupportedError,
Wallet, Wallet,
) )

View file

@ -6,6 +6,7 @@ from typing import AsyncGenerator, Optional
from loguru import logger from loguru import logger
from websocket import create_connection from websocket import create_connection
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -25,7 +26,7 @@ class ClicheWallet(Wallet):
if not settings.cliche_endpoint: if not settings.cliche_endpoint:
raise ValueError("cannot initialize ClicheWallet: missing cliche_endpoint") raise ValueError("cannot initialize ClicheWallet: missing cliche_endpoint")
self.endpoint = self.normalize_endpoint(settings.cliche_endpoint) self.endpoint = normalize_endpoint(settings.cliche_endpoint)
async def cleanup(self): async def cleanup(self):
pass pass

View file

@ -8,6 +8,7 @@ from bolt11.exceptions import Bolt11Exception
from loguru import logger from loguru import logger
from pyln.client import LightningRpc, RpcError from pyln.client import LightningRpc, RpcError
from lnbits.exceptions import UnsupportedError
from lnbits.nodes.cln import CoreLightningNode from lnbits.nodes.cln import CoreLightningNode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
@ -20,7 +21,6 @@ from .base import (
PaymentStatus, PaymentStatus,
PaymentSuccessStatus, PaymentSuccessStatus,
StatusResponse, StatusResponse,
UnsupportedError,
Wallet, Wallet,
) )

View file

@ -9,6 +9,8 @@ from bolt11 import Bolt11Exception
from bolt11.decode import decode from bolt11.decode import decode
from loguru import logger from loguru import logger
from lnbits.exceptions import UnsupportedError
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
@ -18,7 +20,6 @@ from .base import (
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
StatusResponse, StatusResponse,
UnsupportedError,
Wallet, Wallet,
) )
from .macaroon import load_macaroon from .macaroon import load_macaroon
@ -43,7 +44,7 @@ class CoreLightningRestWallet(Wallet):
"invalid corelightning_rest_macaroon provided" "invalid corelightning_rest_macaroon provided"
) )
self.url = self.normalize_endpoint(settings.corelightning_rest_url) self.url = normalize_endpoint(settings.corelightning_rest_url)
headers = { headers = {
"macaroon": macaroon, "macaroon": macaroon,
"encodingtype": "hex", "encodingtype": "hex",

View file

@ -11,6 +11,7 @@ import httpx
from loguru import logger from loguru import logger
from websockets.client import connect from websockets.client import connect
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
@ -39,7 +40,7 @@ class EclairWallet(Wallet):
if not settings.eclair_pass: if not settings.eclair_pass:
raise ValueError("cannot initialize EclairWallet: missing eclair_pass") raise ValueError("cannot initialize EclairWallet: missing eclair_pass")
self.url = self.normalize_endpoint(settings.eclair_url) self.url = normalize_endpoint(settings.eclair_url)
self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws" self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
password = settings.eclair_pass password = settings.eclair_pass

View file

@ -6,6 +6,7 @@ import httpx
from loguru import logger from loguru import logger
from websockets.client import connect from websockets.client import connect
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -36,7 +37,7 @@ class LNbitsWallet(Wallet):
"cannot initialize LNbitsWallet: " "cannot initialize LNbitsWallet: "
"missing lnbits_key or lnbits_admin_key or lnbits_invoice_key" "missing lnbits_key or lnbits_admin_key or lnbits_invoice_key"
) )
self.endpoint = self.normalize_endpoint(settings.lnbits_endpoint) self.endpoint = normalize_endpoint(settings.lnbits_endpoint)
self.ws_url = f"{self.endpoint.replace('http', 'ws', 1)}/api/v1/ws/{key}" self.ws_url = f"{self.endpoint.replace('http', 'ws', 1)}/api/v1/ws/{key}"
self.headers = {"X-Api-Key": key, "User-Agent": settings.user_agent} self.headers = {"X-Api-Key": key, "User-Agent": settings.user_agent}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers) self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)

View file

@ -11,6 +11,7 @@ import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
import lnbits.wallets.lnd_grpc_files.router_pb2 as router import lnbits.wallets.lnd_grpc_files.router_pb2 as router
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
@ -72,9 +73,7 @@ class LndWallet(Wallet):
"cannot initialize LndWallet: missing lnd_grpc_cert or lnd_cert" "cannot initialize LndWallet: missing lnd_grpc_cert or lnd_cert"
) )
self.endpoint = self.normalize_endpoint( self.endpoint = normalize_endpoint(settings.lnd_grpc_endpoint, add_proto=False)
settings.lnd_grpc_endpoint, add_proto=False
)
self.port = int(settings.lnd_grpc_port) self.port = int(settings.lnd_grpc_port)
macaroon = ( macaroon = (

View file

@ -7,6 +7,7 @@ from typing import Any, AsyncGenerator, Dict, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.nodes.lndrest import LndRestNode from lnbits.nodes.lndrest import LndRestNode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
@ -41,7 +42,7 @@ class LndRestWallet(Wallet):
"This only works if you have a publicly issued certificate." "This only works if you have a publicly issued certificate."
) )
self.endpoint = self.normalize_endpoint(settings.lnd_rest_endpoint) self.endpoint = normalize_endpoint(settings.lnd_rest_endpoint)
# if no cert provided it should be public so we set verify to True # if no cert provided it should be public so we set verify to True
# and it will still check for validity of certificate and fail if its not valid # and it will still check for validity of certificate and fail if its not valid

View file

@ -5,6 +5,7 @@ from typing import AsyncGenerator, Dict, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -36,7 +37,7 @@ class LNPayWallet(Wallet):
"missing lnpay_wallet_key or lnpay_admin_key" "missing lnpay_wallet_key or lnpay_admin_key"
) )
self.wallet_key = wallet_key self.wallet_key = wallet_key
self.endpoint = self.normalize_endpoint(settings.lnpay_api_endpoint) self.endpoint = normalize_endpoint(settings.lnpay_api_endpoint)
headers = { headers = {
"X-Api-Key": settings.lnpay_api_key, "X-Api-Key": settings.lnpay_api_key,

View file

@ -7,6 +7,7 @@ from typing import AsyncGenerator, Dict, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -36,7 +37,7 @@ class LnTipsWallet(Wallet):
"missing lntips_api_key or lntips_admin_key or lntips_invoice_key" "missing lntips_api_key or lntips_admin_key or lntips_invoice_key"
) )
self.endpoint = self.normalize_endpoint(settings.lntips_api_endpoint) self.endpoint = normalize_endpoint(settings.lntips_api_endpoint)
headers = { headers = {
"Authorization": f"Basic {key}", "Authorization": f"Basic {key}",

View file

@ -4,6 +4,8 @@ from typing import AsyncGenerator, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.exceptions import UnsupportedError
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -12,7 +14,6 @@ from .base import (
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
StatusResponse, StatusResponse,
UnsupportedError,
Wallet, Wallet,
) )
@ -38,7 +39,7 @@ class OpenNodeWallet(Wallet):
) )
self.key = key self.key = key
self.endpoint = self.normalize_endpoint(settings.opennode_api_endpoint) self.endpoint = normalize_endpoint(settings.opennode_api_endpoint)
headers = { headers = {
"Authorization": self.key, "Authorization": self.key,

View file

@ -9,6 +9,7 @@ import httpx
from loguru import logger from loguru import logger
from websockets.client import connect from websockets.client import connect
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -35,7 +36,7 @@ class PhoenixdWallet(Wallet):
"cannot initialize PhoenixdWallet: missing phoenixd_api_password" "cannot initialize PhoenixdWallet: missing phoenixd_api_password"
) )
self.endpoint = self.normalize_endpoint(settings.phoenixd_api_endpoint) self.endpoint = normalize_endpoint(settings.phoenixd_api_endpoint)
parsed_url = urllib.parse.urlparse(settings.phoenixd_api_endpoint) parsed_url = urllib.parse.urlparse(settings.phoenixd_api_endpoint)
if parsed_url.scheme == "http": if parsed_url.scheme == "http":

View file

@ -7,6 +7,7 @@ from typing import AsyncGenerator, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -36,7 +37,7 @@ class SparkWallet(Wallet):
if not settings.spark_token: if not settings.spark_token:
raise ValueError("cannot initialize SparkWallet: missing spark_token") raise ValueError("cannot initialize SparkWallet: missing spark_token")
url = self.normalize_endpoint(settings.spark_url) url = normalize_endpoint(settings.spark_url)
url = url.replace("/rpc", "") url = url.replace("/rpc", "")
self.token = settings.spark_token self.token = settings.spark_token

View file

@ -6,6 +6,7 @@ from typing import Any, AsyncGenerator, Dict, Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -94,7 +95,7 @@ class StrikeWallet(Wallet):
self._general_limiter = TokenBucket(1000, 600) self._general_limiter = TokenBucket(1000, 600)
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
base_url=self.normalize_endpoint(settings.strike_api_endpoint), base_url=normalize_endpoint(settings.strike_api_endpoint),
headers={ headers={
"Authorization": f"Bearer {settings.strike_api_key}", "Authorization": f"Bearer {settings.strike_api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -6,6 +6,7 @@ import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@ -27,7 +28,7 @@ class ZBDWallet(Wallet):
if not settings.zbd_api_key: if not settings.zbd_api_key:
raise ValueError("cannot initialize ZBDWallet: missing zbd_api_key") raise ValueError("cannot initialize ZBDWallet: missing zbd_api_key")
self.endpoint = self.normalize_endpoint(settings.zbd_api_endpoint) self.endpoint = normalize_endpoint(settings.zbd_api_endpoint)
headers = { headers = {
"apikey": settings.zbd_api_key, "apikey": settings.zbd_api_key,
"User-Agent": settings.user_agent, "User-Agent": settings.user_agent,

View file

@ -8,10 +8,12 @@ from pytest_mock.plugin import MockerFixture
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.models import CreateInvoice, Payment from lnbits.core.models import CreateInvoice, Payment
from lnbits.core.views.payment_api import api_payment from lnbits.core.views.payment_api import api_payment
from lnbits.fiat.base import FiatInvoiceResponse
from lnbits.settings import Settings from lnbits.settings import Settings
from ..helpers import ( from ..helpers import (
get_random_invoice_data, get_random_invoice_data,
get_random_string,
) )
@ -155,6 +157,60 @@ async def test_create_invoice_fiat_amount(client, inkey_headers_to):
assert extra["fiat_rate"] assert extra["fiat_rate"]
@pytest.mark.anyio
async def test_create_fiat_invoice(
client, inkey_headers_to, settings: Settings, mocker: MockerFixture
):
data = await get_random_invoice_data()
data["unit"] = "EUR"
data["fiat_provider"] = "stripe"
settings.stripe_enabled = True
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
fiat_payment_request = "https://stripe.com/pay/session_123"
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id=f"session_123_{get_random_string(10)}",
payment_request=fiat_payment_request,
)
mocker.patch(
"lnbits.fiat.StripeWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000), # 1 BTC = 100 000 EUR, so 1 EUR = 1000 sats
)
response = await client.post(
"/api/v1/payments", json=data, headers=inkey_headers_to
)
assert response.status_code == 201
invoice = response.json()
decode = bolt11.decode(invoice["bolt11"])
assert decode.amount_msat == 10_000_000
assert decode.payment_hash
assert invoice["fiat_provider"] == "stripe"
assert invoice["status"] == "pending"
assert invoice["extra"]["fiat_checking_id"]
assert invoice["extra"]["fiat_payment_request"] == fiat_payment_request
response = await client.get(
f"/api/v1/payments/{decode.payment_hash}", headers=inkey_headers_to
)
assert response.is_success
data = response.json()
assert data["status"] == "pending"
invoice = data["details"]
assert invoice["fiat_provider"] == "stripe"
assert invoice["status"] == "pending"
assert invoice["amount"] == 10_000_000
assert invoice["extra"]["fiat_checking_id"]
assert invoice["extra"]["fiat_payment_request"] == fiat_payment_request
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize("currency", ("msat", "RRR")) @pytest.mark.parametrize("currency", ("msat", "RRR"))
async def test_create_invoice_validates_used_currency( async def test_create_invoice_validates_used_currency(

View file

@ -19,10 +19,10 @@ from lnbits.core.crud import (
from lnbits.core.models import Account, CreateInvoice, PaymentState, User from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.models.users import UpdateSuperuserPassword from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account, update_wallet_balance from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.views.auth_api import first_install from lnbits.core.views.auth_api import first_install
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
from lnbits.settings import AuthMethods, Settings from lnbits.settings import AuthMethods, FiatProviderLimits, Settings
from lnbits.settings import settings as lnbits_settings from lnbits.settings import settings as lnbits_settings
from lnbits.wallets.fake import FakeWallet from lnbits.wallets.fake import FakeWallet
from tests.helpers import ( from tests.helpers import (
@ -255,7 +255,7 @@ async def adminkey_headers_to(to_wallet):
async def invoice(to_wallet): async def invoice(to_wallet):
data = await get_random_invoice_data() data = await get_random_invoice_data()
invoice_data = CreateInvoice(**data) invoice_data = CreateInvoice(**data)
invoice = await _api_payments_create_invoice(invoice_data, to_wallet) invoice = await create_wallet_invoice(to_wallet.id, invoice_data)
yield invoice yield invoice
del invoice del invoice
@ -312,3 +312,4 @@ def _settings_cleanup(settings: Settings):
settings.lnbits_admin_users = [] settings.lnbits_admin_users = []
settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100 settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100
settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200 settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200
settings.stripe_limits = FiatProviderLimits()

View file

@ -0,0 +1,432 @@
import hashlib
import hmac
import time
from unittest.mock import AsyncMock
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud.payments import get_payments
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models.payments import CreateInvoice, PaymentState
from lnbits.core.models.users import User
from lnbits.core.models.wallets import Wallet
from lnbits.core.services import payments
from lnbits.core.services.fiat_providers import check_stripe_signature
from lnbits.core.services.users import create_user_account
from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus
from lnbits.settings import Settings
from tests.helpers import get_random_string
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_missing_provider():
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider=None
)
with pytest.raises(ValueError, match="Fiat provider is required"):
await payments.create_fiat_invoice("wallet_id", invoice_data)
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_provider_not_enabled(settings: Settings):
settings.stripe_enabled = False
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="notarealprovider"
)
with pytest.raises(
ValueError, match="Fiat provider 'notarealprovider' is not enabled"
):
await payments.create_fiat_invoice("wallet_id", invoice_data)
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_with_sat_unit(settings: Settings):
settings.stripe_enabled = True
invoice_data = CreateInvoice(
unit="sat", amount=1.0, memo="Test", fiat_provider="stripe"
)
with pytest.raises(ValueError, match="Fiat provider cannot be used with satoshis"):
await payments.create_fiat_invoice("wallet_id", invoice_data)
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_allowed_users(
to_user: User, settings: Settings
):
settings.stripe_enabled = False
settings.stripe_limits.allowed_users = []
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == []
settings.stripe_enabled = True
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == ["stripe"]
settings.stripe_limits.allowed_users = ["some_other_user_id"]
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == []
settings.stripe_limits.allowed_users.append(to_user.id)
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == ["stripe"]
settings.stripe_enabled = False
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == []
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_fiat_limits_fail(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
settings.stripe_enabled = True
settings.stripe_limits.service_min_amount_sats = 0
settings.stripe_limits.service_max_amount_sats = 105
settings.stripe_limits.service_faucet_wallet_id = None
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
)
with pytest.raises(ValueError, match="Maximum amount is 105 sats for 'stripe'."):
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
settings.stripe_limits.service_min_amount_sats = 1001
settings.stripe_limits.service_max_amount_sats = 10000
with pytest.raises(ValueError, match="Minimum amount is 1001 sats for 'stripe'."):
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
settings.stripe_limits.service_min_amount_sats = 10
settings.stripe_limits.service_max_amount_sats = 10000
settings.stripe_limits.service_max_fee_sats = 100
with pytest.raises(
ValueError, match="Fiat provider 'stripe' service fee wallet missing."
):
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
settings.stripe_limits.service_fee_wallet_id = "not_a_real_wallet_id"
with pytest.raises(
ValueError, match="Fiat provider 'stripe' service fee wallet not found."
):
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
settings.stripe_limits.service_fee_wallet_id = to_wallet.id
settings.stripe_limits.service_faucet_wallet_id = "not_a_real_wallet_id"
with pytest.raises(
ValueError, match="Fiat provider 'stripe' faucet wallet not found."
):
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
user = await create_user_account()
wallet = await create_wallet(user_id=user.id)
settings.stripe_limits.service_faucet_wallet_id = wallet.id
with pytest.raises(
ValueError, match="The amount exceeds the 'stripe'faucet wallet balance."
):
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
@pytest.mark.anyio
async def test_create_wallet_fiat_provider_fails(
settings: Settings, mocker: MockerFixture
):
settings.stripe_enabled = True
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
invoice_data = CreateInvoice(
unit="USD", amount=2.0, memo="Test", fiat_provider="stripe"
)
fiat_mock_response = FiatInvoiceResponse(
ok=False,
error_message="Failed to create invoice",
)
mocker.patch(
"lnbits.fiat.StripeWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
)
user = await create_user_account()
wallet = await create_wallet(user_id=user.id)
with pytest.raises(ValueError, match="Cannot create payment request for 'stripe'."):
await payments.create_fiat_invoice(wallet.id, invoice_data)
wallet_payments = await get_payments(wallet_id=wallet.id)
assert len(wallet_payments) == 1
assert wallet_payments[0].status == PaymentState.FAILED
assert wallet_payments[0].amount == 2000000
assert wallet_payments[0].fee == 0
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_success(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
settings.stripe_enabled = True
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
settings.stripe_limits.service_min_amount_sats = 0
settings.stripe_limits.service_max_amount_sats = 0
settings.stripe_limits.service_faucet_wallet_id = None
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
)
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id=f"session_123_{get_random_string(10)}",
payment_request="https://stripe.com/pay/session_123",
)
mocker.patch(
"lnbits.fiat.StripeWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
)
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
assert payment.status == PaymentState.PENDING
assert payment.amount == 1000_000
assert payment.fiat_provider == "stripe"
assert payment.extra.get("fiat_checking_id") == fiat_mock_response.checking_id
assert (
payment.extra.get("fiat_payment_request")
== "https://stripe.com/pay/session_123"
)
assert payment.checking_id.startswith("fiat_stripe_")
assert payment.fee <= 0
status = await payment.check_status()
assert status.success is False
assert status.pending is True
fiat_mock_status = FiatPaymentStatus(paid=True, fee=123)
mocker.patch(
"lnbits.fiat.StripeWallet.get_invoice_status",
AsyncMock(return_value=fiat_mock_status),
)
status = await payment.check_status()
assert status.paid is True
assert status.success is True
@pytest.mark.anyio
async def test_fiat_service_fee(settings: Settings):
# settings.stripe_limits.service_min_amount_sats = 0
amount_msats = 100_000
fee = payments.service_fee_fiat(amount_msats, "no_such_fiat_provider")
assert fee == 0
settings.stripe_limits.service_fee_wallet_id = None
fee = payments.service_fee_fiat(amount_msats, "stripe")
assert fee == 0
settings.stripe_limits.service_fee_wallet_id = "wallet_id"
fee = payments.service_fee_fiat(amount_msats, "stripe")
assert fee == 0
settings.stripe_limits.service_max_fee_sats = 5
settings.stripe_limits.service_fee_percent = 20
fee = payments.service_fee_fiat(amount_msats, "stripe")
assert fee == 5000
fee = payments.service_fee_fiat(-amount_msats, "stripe")
assert fee == 5000
settings.stripe_limits.service_max_fee_sats = 5
settings.stripe_limits.service_fee_percent = 3
fee = payments.service_fee_fiat(amount_msats, "stripe")
assert fee == 3000
fee = payments.service_fee_fiat(-amount_msats, "stripe")
assert fee == 3000
@pytest.mark.anyio
async def test_handle_fiat_payment_confirmation(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
user = await create_user_account()
service_fee_wallet = await create_wallet(user_id=user.id)
faucet_wallet = await create_wallet(user_id=user.id)
await payments.update_wallet_balance(wallet=faucet_wallet, amount=100_000_000)
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
)
settings.stripe_enabled = True
settings.stripe_limits.service_min_amount_sats = 0
settings.stripe_limits.service_max_amount_sats = 0
settings.stripe_limits.service_fee_percent = 20
settings.stripe_limits.service_fee_wallet_id = service_fee_wallet.id
settings.stripe_limits.service_faucet_wallet_id = faucet_wallet.id
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id=f"session_1000_{get_random_string(10)}",
payment_request="https://stripe.com/pay/session_1000",
)
mocker.patch(
"lnbits.fiat.StripeWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=10000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
)
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
assert payment.status == PaymentState.PENDING
assert payment.amount == 10_000_000
await payments.handle_fiat_payment_confirmation(payment)
# await asyncio.sleep(1) # Simulate async delay
service_fee_payments = await get_payments(wallet_id=service_fee_wallet.id)
assert len(service_fee_payments) == 1
assert service_fee_payments[0].amount == 2_000_000
assert service_fee_payments[0].fee == 0
assert service_fee_payments[0].status == PaymentState.SUCCESS
assert service_fee_payments[0].fiat_provider is None
faucet_wallet_payments = await get_payments(wallet_id=faucet_wallet.id)
# Background tasks may create more payments, so we check for at least 2
# One for the service fee, one for the top-up)
assert len(faucet_wallet_payments) >= 2
faucet_payment = next(
(p for p in faucet_wallet_payments if p.payment_hash == payment.payment_hash),
None,
)
assert faucet_payment
assert faucet_payment.amount == -10_000_000
assert faucet_payment.fee == 0
assert faucet_payment.status == PaymentState.SUCCESS
assert faucet_payment.fiat_provider is None
assert (
faucet_payment.extra.get("fiat_checking_id") == fiat_mock_response.checking_id
)
assert (
faucet_payment.extra.get("fiat_payment_request")
== fiat_mock_response.payment_request
)
assert faucet_payment.checking_id.startswith("internal_fiat_stripe_")
@pytest.mark.parametrize("payload", [b'{"id": "evt_test"}', b"{}", b""])
def test_check_stripe_signature_success(payload):
secret = "whsec_testsecret"
sig_header, _, _ = _make_stripe_sig_header(payload, secret)
# Should not raise
check_stripe_signature(payload, sig_header, secret)
@pytest.mark.parametrize("payload", [b'{"id": "evt_test"}'])
def test_check_stripe_signature_missing_header(payload):
secret = "whsec_testsecret"
with pytest.raises(ValueError, match="Stripe-Signature header is missing"):
check_stripe_signature(payload, None, secret)
def test_check_stripe_signature_missing_secret():
payload = b'{"id": "evt_test"}'
sig_header, _, _ = _make_stripe_sig_header(payload, "whsec_testsecret")
with pytest.raises(ValueError, match="Stripe webhook cannot be verified"):
check_stripe_signature(payload, sig_header, None)
def test_check_stripe_signature_invalid_signature():
payload = b'{"id": "evt_test"}'
secret = "whsec_testsecret"
_, timestamp, _ = _make_stripe_sig_header(payload, secret)
# Tamper with signature
bad_sig_header = f"t={timestamp},v1=deadbeef"
with pytest.raises(ValueError, match="Stripe signature verification failed"):
check_stripe_signature(payload, bad_sig_header, secret)
def test_check_stripe_signature_old_timestamp():
payload = b'{"id": "evt_test"}'
secret = "whsec_testsecret"
old_timestamp = int(time.time()) - 10000 # way outside default tolerance
sig_header, _, _ = _make_stripe_sig_header(payload, secret, timestamp=old_timestamp)
with pytest.raises(ValueError, match="Timestamp outside tolerance"):
check_stripe_signature(payload, sig_header, secret)
def test_check_stripe_signature_future_timestamp():
payload = b'{"id": "evt_test"}'
secret = "whsec_testsecret"
future_timestamp = int(time.time()) + 10000
sig_header, _, _ = _make_stripe_sig_header(
payload, secret, timestamp=future_timestamp
)
with pytest.raises(ValueError, match="Timestamp outside tolerance"):
check_stripe_signature(payload, sig_header, secret)
def test_check_stripe_signature_malformed_header():
payload = b'{"id": "evt_test"}'
secret = "whsec_testsecret"
# Missing v1 part
bad_header = "t=1234567890"
with pytest.raises(Exception): # noqa: B017
check_stripe_signature(payload, bad_header, secret)
# Missing t part
bad_header2 = "v1=abcdef"
with pytest.raises(Exception): # noqa: B017
check_stripe_signature(payload, bad_header2, secret)
# Not split by =
bad_header3 = "t1234567890,v1abcdef"
with pytest.raises(Exception): # noqa: B017
check_stripe_signature(payload, bad_header3, secret)
def test_check_stripe_signature_non_utf8_payload():
secret = "whsec_testsecret"
payload = b"\xff\xfe\xfd" # not valid utf-8
timestamp = int(time.time())
# This will raise UnicodeDecodeError inside check_stripe_signature
signed_payload = f"{timestamp}." + payload.decode(errors="ignore")
signature = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
sig_header = f"t={timestamp},v1={signature}"
with pytest.raises(UnicodeDecodeError):
check_stripe_signature(payload, sig_header, secret)
# Helper to generate a valid Stripe signature header
def _make_stripe_sig_header(payload, secret, timestamp=None):
if timestamp is None:
timestamp = int(time.time())
signed_payload = f"{timestamp}.{payload.decode()}"
signature = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return f"t={timestamp},v1={signature}", timestamp, signature