[feat] Stripe subscription (#3369)

This commit is contained in:
Vlad Stan
2025-10-16 23:14:06 +01:00
committed by GitHub
parent 182894fd93
commit bf06def9b7
7 changed files with 472 additions and 107 deletions
+68 -88
View File
@@ -5,7 +5,7 @@ import time
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment, get_standalone_payment
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
@@ -27,6 +27,73 @@ async def handle_fiat_payment_confirmation(
logger.warning(e)
def check_stripe_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
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)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
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}.",
)
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Connection | None = None
):
@@ -104,90 +171,3 @@ async def _debit_fiat_service_faucet_wallet(
status=PaymentState.SUCCESS,
conn=conn,
)
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: str | None,
secret: str | None,
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)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
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}.",
)
+1 -1
View File
@@ -110,7 +110,7 @@ async def create_payment_request(
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None
):
) -> Payment:
fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name:
raise ValueError("Fiat provider is required for fiat invoices.")
@@ -103,10 +103,10 @@
<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>
- the user completed the checkout process
<li><code>invoice.paid</code></li>
- the invoice was successfully paid (for subscriptions)
</ul>
</q-card-section>
</q-expansion-item>
+112 -2
View File
@@ -1,10 +1,18 @@
from fastapi import APIRouter, Request
import json
from fastapi import APIRouter, Request
from loguru import logger
from lnbits.core.crud.payments import (
get_standalone_payment,
)
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
check_stripe_signature,
handle_stripe_event,
)
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@@ -33,3 +41,105 @@ async def api_generic_webhook_handler(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
)
async def handle_stripe_event(event: dict):
event_id = event.get("id")
event_type = event.get("type")
if event_type == "checkout.session.completed":
await _handle_stripe_checkout_session_completed(event)
elif event_type == "invoice.paid":
await _handle_stripe_subscription_invoice_paid(event)
else:
logger.info(
f"Unhandled Stripe event type: '{event_type}'." f" Event ID: '{event_id}'."
)
async def _handle_stripe_checkout_session_completed(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")
lnbits_action = event_object.get("metadata", {}).get("lnbits_action")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if lnbits_action != "invoice":
logger.warning(f"Stripe event is not an invoice: '{lnbits_action}'.")
return
if not payment_hash:
raise ValueError("Stripe event does not contain a payment hash.")
payment = await get_standalone_payment(payment_hash)
if not payment:
raise ValueError(f"No payment found for hash: '{payment_hash}'.")
await payment.check_fiat_status()
async def _handle_stripe_subscription_invoice_paid(event: dict):
invoice = event.get("data", {}).get("object", {})
parent = invoice.get("parent", {})
currency = invoice.get("currency", "").upper()
if not currency:
raise ValueError("Stripe invoice.paid event missing 'currency'.")
amount_paid = invoice.get("amount_paid")
if not amount_paid:
raise ValueError("Stripe invoice.paid event missing 'amount_paid'.")
payment_options = await _get_stripe_subscription_payment_options(parent)
if not payment_options.wallet_id:
raise ValueError("Stripe invoice.paid event missing 'wallet_id' in metadata.")
memo = " | ".join(
[i.get("description", "") for i in invoice.get("lines", {}).get("data", [])]
+ [payment_options.memo or "", invoice.get("customer_email", "")]
)
extra = {
**(payment_options.extra or {}),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": invoice.get("id"),
"payment_request": invoice.get("hosted_invoice_url"),
},
}
payment = await create_fiat_invoice(
wallet_id=payment_options.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount_paid / 100, # convert cents to dollars
memo=memo,
extra=extra,
fiat_provider="stripe",
),
)
await payment.check_fiat_status()
async def _get_stripe_subscription_payment_options(
parent: dict,
) -> FiatSubscriptionPaymentOptions:
if not parent or not parent.get("type") == "subscription_details":
raise ValueError("Stripe invoice.paid event does not contain a subscription.")
metadata = parent.get("subscription_details", {}).get("metadata", {})
if metadata.get("lnbits_action") != "subscription":
raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.")
if "extra" in metadata:
try:
metadata["extra"] = json.loads(metadata["extra"])
except json.JSONDecodeError as exc:
logger.warning(exc)
metadata["extra"] = {}
return FiatSubscriptionPaymentOptions(**metadata)
+53 -4
View File
@@ -3,9 +3,11 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin
from lnbits.decorators import check_admin, require_admin_key
from lnbits.fiat import StripeWallet, get_fiat_provider
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
@@ -19,20 +21,67 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)
@fiat_router.post(
"/{provider}/subscription",
status_code=HTTPStatus.OK,
)
async def create_subscription(
provider: str,
data: CreateFiatSubscription,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> FiatSubscriptionResponse:
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
raise HTTPException(404, "Fiat provider not found")
wallet_id = data.payment_options.wallet_id
if wallet_id and wallet_id != key_type.wallet.id:
raise HTTPException(
403,
"Wallet id does not match your API key."
"Leave it empty to use your key's wallet.",
)
data.payment_options.wallet_id = key_type.wallet.id
subscription_response = await fiat_provider.create_subscription(
data.subscription_id, data.quantity, data.payment_options
)
return subscription_response
@fiat_router.delete(
"/{provider}/subscription/{subscription_id}",
status_code=HTTPStatus.OK,
)
async def cancel_subscription(
provider: str,
subscription_id: str,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> FiatSubscriptionResponse:
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
raise HTTPException(404, "Fiat provider not found")
resp = await fiat_provider.cancel_subscription(subscription_id, key_type.wallet.id)
return resp
@fiat_router.post(
"/{provider}/connection_token",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def connection_token(provider: str):
provider_wallet = await get_fiat_provider(provider)
fiat_provider = await get_fiat_provider(provider)
if provider == "stripe":
if not isinstance(provider_wallet, StripeWallet):
if not isinstance(fiat_provider, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await provider_wallet.create_terminal_connection_token()
tok = await fiat_provider.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException(