Handle Stripe webhook events

This commit is contained in:
Krzysztof Czerwinski 2024-12-28 17:18:30 +01:00
parent 9cddffb7f0
commit 76b5259f8d
7 changed files with 75 additions and 20 deletions

View File

@ -16,6 +16,7 @@ REDIS_PASSWORD=password
ENABLE_CREDIT=false
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
# What environment things should be logged under: local dev or prod
APP_ENV=local

View File

@ -15,6 +15,7 @@ from backend.data.user import get_user_by_id
from backend.util.settings import Settings
settings = Settings()
stripe.api_key = settings.secrets.stripe_api_key
class UserCreditBase(ABC):
@ -66,7 +67,7 @@ class UserCreditBase(ABC):
pass
@abstractmethod
async def top_up_intent(self, user_id: str, amount: int) -> RedirectResponse:
async def top_up_intent(self, user_id: str, amount: int) -> str:
"""
Create a payment intent to top up the credits for the user.
@ -75,7 +76,17 @@ class UserCreditBase(ABC):
amount (int): The amount to top up.
Returns:
RedirectResponse: The redirect response to the payment page.
str: The redirect url to the payment page.
"""
pass
@abstractmethod
async def fulfill_checkout(self, session_id):
"""
Fulfill the Stripe checkout session.
Args:
session_id (str): The checkout session ID.
"""
pass
@ -83,7 +94,6 @@ class UserCreditBase(ABC):
class UserCredit(UserCreditBase):
def __init__(self):
self.num_user_credits_refill = settings.config.num_user_credits_refill
stripe.api_key = settings.secrets.stripe_api_key
async def get_or_refill_credit(self, user_id: str) -> int:
cur_time = self.time_now()
@ -229,7 +239,7 @@ class UserCredit(UserCreditBase):
}
)
async def top_up_intent(self, user_id: str, amount: int) -> RedirectResponse:
async def top_up_intent(self, user_id: str, amount: int) -> str:
user = await get_user_by_id(user_id)
if not user:
@ -268,7 +278,7 @@ class UserCredit(UserCreditBase):
# Create pending transaction
await CreditTransaction.prisma().create(
data={
"transactionKey": checkout_session.id,# TODO kcze add new model field?
"transactionKey": checkout_session.id, # TODO kcze add new model field?
"userId": user_id,
"amount": amount,
"type": CreditTransactionType.TOP_UP,
@ -277,10 +287,11 @@ class UserCredit(UserCreditBase):
}
)
return RedirectResponse(checkout_session.url or "", 303)
return checkout_session.url or ""
# https://docs.stripe.com/checkout/fulfillment
async def fulfill_checkout(self, session_id):
print("fulfill_checkout", session_id)
# Retrieve CreditTransaction
credit_transaction = await CreditTransaction.prisma().find_first_or_raise(
where={"transactionKey": session_id}
@ -295,10 +306,16 @@ class UserCredit(UserCreditBase):
# Check the Checkout Session's payment_status property
# to determine if fulfillment should be peformed
if checkout_session.payment_status != 'unpaid':
if checkout_session.payment_status != "unpaid":
print("Payment status is not unpaid!")
# Activate the CreditTransaction
await CreditTransaction.prisma().update(
where={"transactionKey": session_id},
where={
"creditTransactionIdentifier": {
"transactionKey": session_id,
"userId": credit_transaction.userId,
}
},
data={
"isActive": True,
"createdAt": self.time_now(),
@ -317,11 +334,15 @@ class DisabledUserCredit(UserCreditBase):
async def top_up_credits(self, *args, **kwargs):
pass
async def top_up_intent(self, *args, **kwargs) -> RedirectResponse:
return RedirectResponse("")
async def top_up_intent(self, *args, **kwargs) -> str:
return ""
async def fulfill_checkout(self, *args, **kwargs):
pass
def get_user_credit_model() -> UserCreditBase:
# return UserCredit()
if settings.config.enable_credit.lower() == "true":
return UserCredit()
else:

View File

@ -3,11 +3,13 @@ import logging
from collections import defaultdict
from typing import TYPE_CHECKING, Annotated, Any, Sequence
from fastapi.responses import RedirectResponse
import pydantic
import stripe
from autogpt_libs.auth.middleware import auth_middleware
from autogpt_libs.feature_flag.client import feature_flag
from autogpt_libs.utils.cache import thread_cached
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from typing_extensions import Optional, TypedDict
import backend.data.block
@ -138,11 +140,41 @@ async def get_user_credits(
return {"credits": max(await _user_credit_model.get_or_refill_credit(user_id), 0)}
@v1_router.post(path="/credits", dependencies=[Depends(auth_middleware)])
@v1_router.post(path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)])
async def request_top_up(
user_id: Annotated[str, Depends(get_user_id)], request: RequestTopUp
request: RequestTopUp, user_id: Annotated[str, Depends(get_user_id)]
):
return _user_credit_model.top_up_intent(user_id, request.amount)
checkout_url = await _user_credit_model.top_up_intent(user_id, request.amount)
return {"checkout_url": checkout_url}
@v1_router.post(path="/credits/stripe_webhook", tags=["credits"])
async def stripe_webhook(request: Request):
# Get the raw request body
payload = await request.body()
# Get the signature header
sig_header = request.headers.get('stripe-signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.secrets.stripe_webhook_secret
)
except ValueError:
# Invalid payload
raise HTTPException(status_code=400)
except stripe.SignatureVerificationError:
# Invalid signature
raise HTTPException(status_code=400)
print(event)
if (
event['type'] == 'checkout.session.completed'
or event['type'] == 'checkout.session.async_payment_succeeded'
):
await _user_credit_model.fulfill_checkout(event['data']['object']['id'])
return Response(status_code=200)
########################################################

View File

@ -298,6 +298,7 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
fal_key: str = Field(default="", description="FAL API key")
stripe_api_key: str = Field(default="", description="Stripe API Key")
stripe_webhook_secret: str = Field(default="", description="Stripe Webhook Secret")
# Add more secret fields as needed

View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aio-pika"
@ -4339,4 +4339,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.13"
content-hash = "180e010fe51c228a973a76984523deedda9ebc9b6059ec4a5c5adbe76ce4509e"
content-hash = "1f7b98944844245f1574339d622e8f1ecddf08aa1e9efa84682fd51ab0556a75"

View File

@ -10,7 +10,7 @@ from backend.integrations.credentials_store import openai_credentials
from backend.util.test import SpinTestServer
REFILL_VALUE = 1000
user_credit = UserCredit(REFILL_VALUE)
user_credit = UserCredit()
@pytest.mark.asyncio(scope="session")

View File

@ -85,7 +85,7 @@ export default class BackendAPI {
}
}
requestTopUp(amount: number) {
requestTopUp(amount: number): Promise<{ checkout_url: string }> {
return this._request("POST", "/credits", { amount });
}
@ -446,7 +446,7 @@ export default class BackendAPI {
await new Promise((resolve) => setTimeout(resolve, 100 * retryCount));
}
}
console.log("Request: ", method, path, "from: ", page);
console.log("Request: ", method, this.baseUrl, path, "from: ", page);
if (token === "no-token-found") {
console.warn(
"No auth token found after retries. This may indicate a session sync issue between client and server.",