[feat] Add stripe payments (#3184)

This commit is contained in:
Vlad Stan
2025-07-08 10:17:19 +02:00
committed by dni ⚡
parent 5c9511ccfe
commit 695e9b6471
51 changed files with 2014 additions and 175 deletions
+4
View File
@@ -5,7 +5,9 @@ from .views.admin_api import admin_router
from .views.api import api_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
@@ -34,10 +36,12 @@ def init_core_routers(app: FastAPI):
app.include_router(wallet_router)
app.include_router(api_router)
app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(fiat_router)
__all__ = ["core_app", "core_app_extra", "db"]
+1
View File
@@ -195,6 +195,7 @@ async def get_user_from_account(
wallets=wallets,
admin=account.is_admin,
super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None,
)
+4
View File
@@ -719,3 +719,7 @@ async def m032_add_external_id_to_accounts(db: Connection):
Used for external account linking.
"""
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")
+54 -2
View File
@@ -8,6 +8,13 @@ from fastapi import Query
from pydantic import BaseModel, Field, validator
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.wallets import get_funding_source
from lnbits.wallets.base import (
@@ -60,6 +67,8 @@ class Payment(BaseModel):
amount: int
fee: int
bolt11: str
# payment_request: str | None
fiat_provider: str | None = None
status: str = PaymentState.PENDING
memo: str | None = None
expiry: datetime | None = None
@@ -107,14 +116,23 @@ class Payment(BaseModel):
@property
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.success:
return PaymentSuccessStatus()
if self.failed:
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()
funding_source = get_funding_source()
if self.is_out:
@@ -123,6 +141,39 @@ class Payment(BaseModel):
status = await funding_source.get_invoice_status(self.checking_id)
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):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
@@ -206,6 +257,7 @@ class CreateInvoice(BaseModel):
webhook: str | None = None
bolt11: str | None = None
lnurl_callback: str | None = None
fiat_provider: str | None = None
@validator("unit")
@classmethod
+3
View File
@@ -110,11 +110,13 @@ class Account(BaseModel):
is_super_user: 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):
super().__init__(**data)
self.is_super_user = settings.is_super_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:
"""sets and returns the hashed password"""
@@ -191,6 +193,7 @@ class User(BaseModel):
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False
extra: UserExtra = UserExtra()
+8
View File
@@ -8,11 +8,15 @@ from .payments import (
calculate_fiat_amounts,
check_transaction_status,
check_wallet_limits,
create_fiat_invoice,
create_invoice,
create_wallet_invoice,
fee_reserve,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
service_fee,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
)
@@ -44,10 +48,14 @@ __all__ = [
"check_transaction_status",
"check_wallet_limits",
"create_invoice",
"create_wallet_invoice",
"create_fiat_invoice",
"fee_reserve",
"fee_reserve_total",
"get_payments_daily_stats",
"pay_invoice",
"service_fee",
"update_pending_payment",
"update_pending_payments",
"update_wallet_balance",
# settings
+92
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}.",
)
+280 -1
View File
@@ -1,8 +1,10 @@
import asyncio
import json
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode
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.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
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.tasks import create_task
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.wallets import fake_wallet, get_funding_source
@@ -94,6 +98,122 @@ async def pay_invoice(
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(
*,
wallet_id: str,
@@ -226,6 +346,26 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
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(
wallet: Wallet,
amount: int,
@@ -449,6 +589,20 @@ async def get_payments_daily_stats(
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(
wallet_id: str,
create_payment_model: CreatePayment,
@@ -573,6 +727,8 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
@@ -728,3 +884,126 @@ async def _credit_service_fee_wallet(
status=PaymentState.SUCCESS,
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.",
)
@@ -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>
+9 -1
View File
@@ -115,6 +115,13 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></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
name="users"
icon="group"
@@ -183,7 +190,8 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
>
{% include "admin/_tab_funding.html" %} {% include
"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_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
+104 -24
View File
@@ -211,7 +211,12 @@
</q-card>
<div id="hiddenQrCodeContainer" style="display: none">
<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>
</div>
</div>
@@ -619,14 +624,30 @@
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% else %}
<q-select
filled
dense
v-model="receive.unit"
type="text"
:label="$t('unit')"
:options="receive.units"
></q-select>
<div class="row">
<div class="col-10">
<q-select
filled
dense
v-model="receive.unit"
type="text"
:label="$t('unit')"
:options="receive.units"
></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
ref="setAmount"
filled
@@ -644,9 +665,59 @@
<q-input
filled
dense
type="textarea"
rows="2"
v-model.trim="receive.data.memo"
:label="$t('memo')"
></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">
<q-btn
unelevated
@@ -680,7 +751,14 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<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>
</a>
</div>
@@ -691,25 +769,27 @@
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span>
</h5>
<q-chip v-if="hasNfc" outline square color="positive">
<q-avatar
icon="nfc"
color="positive"
text-color="white"
></q-avatar>
<span v-text="$t('nfc_supported')"></span>
</q-chip>
<span
v-else
class="text-caption text-grey"
v-text="$t('nfc_not_supported')"
></span>
<div v-if="!receive.fiatPaymentReq">
<q-chip v-if="hasNfc" outline square color="positive">
<q-avatar
icon="nfc"
color="positive"
text-color="white"
></q-avatar>
<span v-text="$t('nfc_supported')"></span>
</q-chip>
<span
v-else
class="text-caption text-grey"
v-text="$t('nfc_not_supported')"
></span>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(receive.paymentReq)"
@click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
+35
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}'.",
)
+18
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)
+13 -74
View File
@@ -34,13 +34,8 @@ from lnbits.core.models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentWalletStats,
Wallet,
)
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.decorators import (
WalletTypeInfo,
@@ -67,9 +62,12 @@ from ..crud import (
get_wallet_for_key,
)
from ..services import (
create_invoice,
create_fiat_invoice,
create_wallet_invoice,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
update_pending_payment,
update_pending_payments,
)
@@ -198,69 +196,6 @@ async def api_payments_paginated(
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(
"/all/paginated",
name="Payment List",
@@ -308,6 +243,7 @@ async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment:
wallet_id = wallet.wallet.id
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
@@ -315,21 +251,24 @@ async def api_payments_create(
detail="Missing BOLT11 invoice",
)
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
wallet_id=wallet_id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return payment
elif not invoice_data.out:
# invoice key
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
if invoice_data.out:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
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")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: