[feat] Stripe subscription (#3369)
This commit is contained in:
@@ -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}.",
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user