mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 23:28:07 -05:00
feat(platform): Implement top-up flow for PAYG System (#9050)
This PR adds Stripe integration and payment processing for topping-up user accounts with credits. ### Changes 🏗️ Includes: - https://github.com/Significant-Gravitas/AutoGPT/pull/9176 #### Top-up flow 1. To top-up a user visits their settings and clicks `Credits` button (it's unavailable if `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` isn't present) 2. User inputs top-up amount (min 5$ in 1$ increments) and click the button to confirm. 3. Backend receives top-up request, creates database entry and requests stripe to provide url for this specific checkout. 4. User gets redirected to externally hosted Stripe checkout page, after payment (or cancelling) they get redirected back to Credits page. 5. In the meantime Stripe processes payment and sends webhook confirmation to the backend, backend updates database to activate bought credits. 6. Credits page shows success (or failure) information (by using url param `topup=success|cancel`). Credit counter won't update without refreshing the page unless payment was confirmed before user was back on Credits page which is the case when testing checkout locally. <img width="804" alt="Screenshot 2025-01-01 at 2 55 35 PM" src="https://github.com/user-attachments/assets/22fb518d-b30b-4154-bb4b-edea1d57b6c2" /> #### Backend - Add `stripe` package - Add environment variables: - `STRIPE_API_KEY` - `STRIPE_WEBHOOK_SECRET` - Add routes: - `POST /credits`: top-up request, returns Stripe checkout url. - `POST /credits/stripe_webhook`: Stripe webhook endpoint to notify of successful payment. - `PATCH /credits`: prompts beckend to check payment status. It's an additional failsafe in case webhook fails. - Update `credit.py` and related files to handle top-up request and payment confirmation #### Frontend - Add `stripe-js` package - Add `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` environment variable - Modify user settings sidebar to show `Credits` if `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` is available - Add `store/credits` page where user can top-up their account, it shows confirmation (or failure) after completing checkout. - Add `useCredits` hook that returns user credits and allows to request top-up. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: <!-- Put your test plan here: --> - [ ] ... <details> <summary>Example test plan</summary> - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly </details> #### For configuration changes: - [x] `.env.example` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**) <details> <summary>Examples of configuration changes</summary> - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases </details> --------- Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
This commit is contained in:
committed by
GitHub
parent
9d79bfadea
commit
04915f2db0
@@ -15,6 +15,9 @@ REDIS_PORT=6379
|
||||
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
|
||||
# What environment to behave as: "local" or "cloud"
|
||||
@@ -36,7 +39,7 @@ SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
|
||||
## to use the platform's webhook-related functionality.
|
||||
## If you are developing locally, you can use something like ngrok to get a publc URL
|
||||
## and tunnel it to your locally running backend.
|
||||
PLATFORM_BASE_URL=https://your-public-url-here
|
||||
PLATFORM_BASE_URL=http://localhost:3000
|
||||
|
||||
## == INTEGRATION CREDENTIALS == ##
|
||||
# Each set of server side credentials is required for the corresponding 3rd party
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import stripe
|
||||
from prisma import Json
|
||||
from prisma.enums import CreditTransactionType
|
||||
from prisma.errors import UniqueViolationError
|
||||
from prisma.models import CreditTransaction
|
||||
from prisma.models import CreditTransaction, User
|
||||
from prisma.types import CreditTransactionCreateInput, CreditTransactionWhereInput
|
||||
|
||||
from backend.data import db
|
||||
from backend.data.block import Block, BlockInput, get_block
|
||||
from backend.data.block_cost_config import BLOCK_COSTS
|
||||
from backend.data.cost import BlockCost, BlockCostType
|
||||
from backend.util.settings import Config
|
||||
from backend.data.user import get_user_by_id
|
||||
from backend.util.settings import Settings
|
||||
|
||||
config = Config()
|
||||
settings = Settings()
|
||||
stripe.api_key = settings.secrets.stripe_api_key
|
||||
|
||||
|
||||
class UserCreditBase(ABC):
|
||||
def __init__(self, num_user_credits_refill: int):
|
||||
self.num_user_credits_refill = num_user_credits_refill
|
||||
|
||||
@abstractmethod
|
||||
async def get_or_refill_credit(self, user_id: str) -> int:
|
||||
async def get_credits(self, user_id: str) -> int:
|
||||
"""
|
||||
Get the current credit for the user and refill if no transaction has been made in the current cycle.
|
||||
Get the current credits for the user.
|
||||
|
||||
Returns:
|
||||
int: The current credit for the user.
|
||||
int: The current credits for the user.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -32,7 +34,6 @@ class UserCreditBase(ABC):
|
||||
async def spend_credits(
|
||||
self,
|
||||
user_id: str,
|
||||
user_credit: int,
|
||||
block_id: str,
|
||||
input_data: BlockInput,
|
||||
data_size: float,
|
||||
@@ -43,7 +44,6 @@ class UserCreditBase(ABC):
|
||||
|
||||
Args:
|
||||
user_id (str): The user ID.
|
||||
user_credit (int): The current credit for the user.
|
||||
block_id (str): The block ID.
|
||||
input_data (BlockInput): The input data for the block.
|
||||
data_size (float): The size of the data being processed.
|
||||
@@ -57,7 +57,7 @@ class UserCreditBase(ABC):
|
||||
@abstractmethod
|
||||
async def top_up_credits(self, user_id: str, amount: int):
|
||||
"""
|
||||
Top up the credits for the user.
|
||||
Top up the credits for the user immediately.
|
||||
|
||||
Args:
|
||||
user_id (str): The user ID.
|
||||
@@ -65,51 +65,139 @@ class UserCreditBase(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def top_up_intent(self, user_id: str, amount: int) -> str:
|
||||
"""
|
||||
Create a payment intent to top up the credits for the user.
|
||||
|
||||
class UserCredit(UserCreditBase):
|
||||
async def get_or_refill_credit(self, user_id: str) -> int:
|
||||
cur_time = self.time_now()
|
||||
cur_month = cur_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
nxt_month = (
|
||||
cur_month.replace(month=cur_month.month + 1)
|
||||
if cur_month.month < 12
|
||||
else cur_month.replace(year=cur_month.year + 1, month=1)
|
||||
Args:
|
||||
user_id (str): The user ID.
|
||||
amount (int): The amount of credits to top up.
|
||||
|
||||
Returns:
|
||||
str: The redirect url to the payment page.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fulfill_checkout(
|
||||
self, *, session_id: str | None = None, user_id: str | None = None
|
||||
):
|
||||
"""
|
||||
Fulfill the Stripe checkout session.
|
||||
|
||||
Args:
|
||||
session_id (str | None): The checkout session ID. Will try to fulfill most recent if None.
|
||||
user_id (str | None): The user ID must be provided if session_id is None.
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def time_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
# ====== Transaction Helper Methods ====== #
|
||||
# Any modifications to the transaction table should only be done through these methods #
|
||||
|
||||
async def _get_credits(self, user_id: str) -> tuple[int, datetime]:
|
||||
"""
|
||||
Returns the current balance of the user & the latest balance snapshot time.
|
||||
"""
|
||||
top_time = self.time_now()
|
||||
snapshot = await CreditTransaction.prisma().find_first(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"createdAt": {"lte": top_time},
|
||||
"isActive": True,
|
||||
"runningBalance": {"not": None}, # type: ignore
|
||||
},
|
||||
order={"createdAt": "desc"},
|
||||
)
|
||||
if snapshot:
|
||||
return snapshot.runningBalance or 0, snapshot.createdAt
|
||||
|
||||
user_credit = await CreditTransaction.prisma().group_by(
|
||||
# No snapshot: Manually calculate balance using current month's transactions.
|
||||
low_time = top_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
transactions = await CreditTransaction.prisma().group_by(
|
||||
by=["userId"],
|
||||
sum={"amount": True},
|
||||
where={
|
||||
"userId": user_id,
|
||||
"createdAt": {"gte": cur_month, "lt": nxt_month},
|
||||
"createdAt": {"gte": low_time, "lte": top_time},
|
||||
"isActive": True,
|
||||
},
|
||||
)
|
||||
transaction_balance = (
|
||||
transactions[0].get("_sum", {}).get("amount", 0) if transactions else 0
|
||||
)
|
||||
return transaction_balance, datetime.min
|
||||
|
||||
if user_credit:
|
||||
credit_sum = user_credit[0].get("_sum") or {}
|
||||
return credit_sum.get("amount", 0)
|
||||
async def _enable_transaction(
|
||||
self, transaction_key: str, user_id: str, metadata: Json
|
||||
):
|
||||
|
||||
key = f"MONTHLY-CREDIT-TOP-UP-{cur_month}"
|
||||
transaction = await CreditTransaction.prisma().find_first_or_raise(
|
||||
where={"transactionKey": transaction_key, "userId": user_id}
|
||||
)
|
||||
|
||||
try:
|
||||
await CreditTransaction.prisma().create(
|
||||
if transaction.isActive:
|
||||
return
|
||||
|
||||
async with db.locked_transaction(f"usr_trx_{user_id}"):
|
||||
user_balance, _ = await self._get_credits(user_id)
|
||||
|
||||
await CreditTransaction.prisma().update(
|
||||
where={
|
||||
"creditTransactionIdentifier": {
|
||||
"transactionKey": transaction_key,
|
||||
"userId": user_id,
|
||||
}
|
||||
},
|
||||
data={
|
||||
"amount": self.num_user_credits_refill,
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
"userId": user_id,
|
||||
"transactionKey": key,
|
||||
"isActive": True,
|
||||
"runningBalance": user_balance + transaction.amount,
|
||||
"createdAt": self.time_now(),
|
||||
}
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
except UniqueViolationError:
|
||||
pass # Already refilled this month
|
||||
|
||||
return self.num_user_credits_refill
|
||||
async def _add_transaction(
|
||||
self,
|
||||
user_id: str,
|
||||
amount: int,
|
||||
transaction_type: CreditTransactionType,
|
||||
is_active: bool = True,
|
||||
transaction_key: str | None = None,
|
||||
block_id: str | None = None,
|
||||
metadata: Json = Json({}),
|
||||
):
|
||||
async with db.locked_transaction(f"usr_trx_{user_id}"):
|
||||
# Get latest balance snapshot
|
||||
user_balance, _ = await self._get_credits(user_id)
|
||||
if amount < 0 and user_balance < abs(amount):
|
||||
raise ValueError(
|
||||
f"Insufficient balance for user {user_id}, balance: {user_balance}, amount: {amount}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def time_now():
|
||||
return datetime.now(timezone.utc)
|
||||
# Create the transaction
|
||||
transaction_data: CreditTransactionCreateInput = {
|
||||
"userId": user_id,
|
||||
"amount": amount,
|
||||
"runningBalance": user_balance + amount,
|
||||
"type": transaction_type,
|
||||
"blockId": block_id,
|
||||
"metadata": metadata,
|
||||
"isActive": is_active,
|
||||
"createdAt": self.time_now(),
|
||||
}
|
||||
if transaction_key:
|
||||
transaction_data["transactionKey"] = transaction_key
|
||||
await CreditTransaction.prisma().create(data=transaction_data)
|
||||
|
||||
return user_balance + amount
|
||||
|
||||
|
||||
class UserCredit(UserCreditBase):
|
||||
|
||||
def _block_usage_cost(
|
||||
self,
|
||||
@@ -148,8 +236,8 @@ class UserCredit(UserCreditBase):
|
||||
) -> bool:
|
||||
"""
|
||||
Filter rules:
|
||||
- If costFilter is an object, then check if costFilter is the subset of inputValues
|
||||
- Otherwise, check if costFilter is equal to inputValues.
|
||||
- If cost_filter is an object, then check if cost_filter is the subset of input_data
|
||||
- Otherwise, check if cost_filter is equal to input_data.
|
||||
- Undefined, null, and empty string are considered as equal.
|
||||
"""
|
||||
if not isinstance(cost_filter, dict) or not isinstance(input_data, dict):
|
||||
@@ -164,12 +252,10 @@ class UserCredit(UserCreditBase):
|
||||
async def spend_credits(
|
||||
self,
|
||||
user_id: str,
|
||||
user_credit: int,
|
||||
block_id: str,
|
||||
input_data: BlockInput,
|
||||
data_size: float,
|
||||
run_time: float,
|
||||
validate_balance: bool = True,
|
||||
) -> int:
|
||||
block = get_block(block_id)
|
||||
if not block:
|
||||
@@ -178,42 +264,169 @@ class UserCredit(UserCreditBase):
|
||||
cost, matching_filter = self._block_usage_cost(
|
||||
block=block, input_data=input_data, data_size=data_size, run_time=run_time
|
||||
)
|
||||
if cost <= 0:
|
||||
if cost == 0:
|
||||
return 0
|
||||
|
||||
if validate_balance and user_credit < cost:
|
||||
raise ValueError(f"Insufficient credit: {user_credit} < {cost}")
|
||||
|
||||
await CreditTransaction.prisma().create(
|
||||
data={
|
||||
"userId": user_id,
|
||||
"amount": -cost,
|
||||
"type": CreditTransactionType.USAGE,
|
||||
"blockId": block.id,
|
||||
"metadata": Json(
|
||||
{
|
||||
"block": block.name,
|
||||
"input": matching_filter,
|
||||
}
|
||||
),
|
||||
"createdAt": self.time_now(),
|
||||
}
|
||||
await self._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=-cost,
|
||||
transaction_type=CreditTransactionType.USAGE,
|
||||
block_id=block.id,
|
||||
metadata=Json(
|
||||
{
|
||||
"block": block.name,
|
||||
"input": matching_filter,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
return cost
|
||||
|
||||
async def top_up_credits(self, user_id: str, amount: int):
|
||||
await CreditTransaction.prisma().create(
|
||||
data={
|
||||
"userId": user_id,
|
||||
"amount": amount,
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
"createdAt": self.time_now(),
|
||||
}
|
||||
if amount < 0:
|
||||
raise ValueError(f"Top up amount must not be negative: {amount}")
|
||||
|
||||
await self._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _get_stripe_customer_id(user_id: str) -> str:
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
if user.stripeCustomerId:
|
||||
return user.stripeCustomerId
|
||||
|
||||
customer = stripe.Customer.create(name=user.name or "", email=user.email)
|
||||
await User.prisma().update(
|
||||
where={"id": user_id}, data={"stripeCustomerId": customer.id}
|
||||
)
|
||||
return customer.id
|
||||
|
||||
async def top_up_intent(self, user_id: str, amount: int) -> str:
|
||||
# Create checkout session
|
||||
# https://docs.stripe.com/checkout/quickstart?client=react
|
||||
# unit_amount param is always in the smallest currency unit (so cents for usd)
|
||||
# which is equal to amount of credits
|
||||
checkout_session = stripe.checkout.Session.create(
|
||||
customer=await self._get_stripe_customer_id(user_id),
|
||||
line_items=[
|
||||
{
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"product_data": {
|
||||
"name": "AutoGPT Platform Credits",
|
||||
},
|
||||
"unit_amount": amount,
|
||||
},
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
mode="payment",
|
||||
success_url=settings.config.platform_base_url
|
||||
+ "/store/credits?topup=success",
|
||||
cancel_url=settings.config.platform_base_url
|
||||
+ "/store/credits?topup=cancel",
|
||||
)
|
||||
|
||||
# Create pending transaction
|
||||
await self._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
transaction_key=checkout_session.id,
|
||||
is_active=False,
|
||||
metadata=Json({"checkout_session": checkout_session}),
|
||||
)
|
||||
|
||||
return checkout_session.url or ""
|
||||
|
||||
# https://docs.stripe.com/checkout/fulfillment
|
||||
async def fulfill_checkout(
|
||||
self, *, session_id: str | None = None, user_id: str | None = None
|
||||
):
|
||||
if (not session_id and not user_id) or (session_id and user_id):
|
||||
raise ValueError("Either session_id or user_id must be provided")
|
||||
|
||||
# Retrieve CreditTransaction
|
||||
find_filter: CreditTransactionWhereInput = {
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
"isActive": False,
|
||||
}
|
||||
if session_id:
|
||||
find_filter["transactionKey"] = session_id
|
||||
if user_id:
|
||||
find_filter["userId"] = user_id
|
||||
|
||||
# Find the most recent inactive top-up transaction
|
||||
credit_transaction = await CreditTransaction.prisma().find_first_or_raise(
|
||||
where=find_filter,
|
||||
order={"createdAt": "desc"},
|
||||
)
|
||||
|
||||
# This can be called multiple times for one id, so ignore if already fulfilled
|
||||
if not credit_transaction:
|
||||
return
|
||||
|
||||
# Retrieve the Checkout Session from the API
|
||||
checkout_session = stripe.checkout.Session.retrieve(
|
||||
credit_transaction.transactionKey
|
||||
)
|
||||
|
||||
# Check the Checkout Session's payment_status property
|
||||
# to determine if fulfillment should be performed
|
||||
if checkout_session.payment_status in ["paid", "no_payment_required"]:
|
||||
await self._enable_transaction(
|
||||
transaction_key=credit_transaction.transactionKey,
|
||||
user_id=credit_transaction.userId,
|
||||
metadata=Json({"checkout_session": checkout_session}),
|
||||
)
|
||||
|
||||
async def get_credits(self, user_id: str) -> int:
|
||||
balance, _ = await self._get_credits(user_id)
|
||||
return balance
|
||||
|
||||
|
||||
class BetaUserCredit(UserCredit):
|
||||
"""
|
||||
This is a temporary class to handle the test user utilizing monthly credit refill.
|
||||
TODO: Remove this class & its feature toggle.
|
||||
"""
|
||||
|
||||
def __init__(self, num_user_credits_refill: int):
|
||||
self.num_user_credits_refill = num_user_credits_refill
|
||||
|
||||
async def get_credits(self, user_id: str) -> int:
|
||||
cur_time = self.time_now().date()
|
||||
balance, snapshot_time = await self._get_credits(user_id)
|
||||
if (snapshot_time.year, snapshot_time.month) == (cur_time.year, cur_time.month):
|
||||
return balance
|
||||
|
||||
try:
|
||||
await CreditTransaction.prisma().create(
|
||||
data={
|
||||
"transactionKey": f"MONTHLY-CREDIT-TOP-UP-{cur_time}",
|
||||
"userId": user_id,
|
||||
"amount": self.num_user_credits_refill,
|
||||
"runningBalance": self.num_user_credits_refill,
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
"metadata": Json({}),
|
||||
"isActive": True,
|
||||
"createdAt": self.time_now(),
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
pass # Already refilled this month
|
||||
|
||||
return self.num_user_credits_refill
|
||||
|
||||
|
||||
class DisabledUserCredit(UserCreditBase):
|
||||
async def get_or_refill_credit(self, *args, **kwargs) -> int:
|
||||
async def get_credits(self, *args, **kwargs) -> int:
|
||||
return 0
|
||||
|
||||
async def spend_credits(self, *args, **kwargs) -> int:
|
||||
@@ -222,12 +435,21 @@ class DisabledUserCredit(UserCreditBase):
|
||||
async def top_up_credits(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def top_up_intent(self, *args, **kwargs) -> str:
|
||||
return ""
|
||||
|
||||
async def fulfill_checkout(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def get_user_credit_model() -> UserCreditBase:
|
||||
if config.enable_credit.lower() == "true":
|
||||
return UserCredit(config.num_user_credits_refill)
|
||||
else:
|
||||
return DisabledUserCredit(0)
|
||||
if not settings.config.enable_credit:
|
||||
return DisabledUserCredit()
|
||||
|
||||
if settings.config.enable_beta_monthly_credit:
|
||||
return BetaUserCredit(settings.config.num_user_credits_refill)
|
||||
|
||||
return UserCredit()
|
||||
|
||||
|
||||
def get_block_costs() -> dict[str, list[BlockCost]]:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import zlib
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -54,6 +55,14 @@ async def transaction():
|
||||
yield tx
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def locked_transaction(key: str):
|
||||
lock_key = zlib.crc32(key.encode("utf-8"))
|
||||
async with transaction() as tx:
|
||||
await tx.execute_raw(f"SELECT pg_advisory_xact_lock({lock_key})")
|
||||
yield tx
|
||||
|
||||
|
||||
class BaseDbModel(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
|
||||
|
||||
@@ -78,12 +78,8 @@ class DatabaseManager(AppService):
|
||||
|
||||
# Credits
|
||||
user_credit_model = get_user_credit_model()
|
||||
get_or_refill_credit = cast(
|
||||
Callable[[Any, str], int],
|
||||
exposed_run_and_wait(user_credit_model.get_or_refill_credit),
|
||||
)
|
||||
spend_credits = cast(
|
||||
Callable[[Any, str, int, str, dict[str, str], float, float], int],
|
||||
Callable[[Any, str, str, dict[str, str], float, float], int],
|
||||
exposed_run_and_wait(user_credit_model.spend_credits),
|
||||
)
|
||||
|
||||
|
||||
@@ -183,9 +183,6 @@ def execute_node(
|
||||
|
||||
output_size = 0
|
||||
end_status = ExecutionStatus.COMPLETED
|
||||
credit = db_client.get_or_refill_credit(user_id)
|
||||
if credit < 0:
|
||||
raise ValueError(f"Insufficient credit: {credit}")
|
||||
|
||||
try:
|
||||
for output_name, output_data in node_block.execute(
|
||||
@@ -241,7 +238,7 @@ def execute_node(
|
||||
if res.end_time and res.start_time
|
||||
else 0
|
||||
)
|
||||
db_client.spend_credits(user_id, credit, node_block.id, input_data, s, t)
|
||||
db_client.spend_credits(user_id, node_block.id, input_data, s, t)
|
||||
|
||||
# Update execution stats
|
||||
if execution_stats is not None:
|
||||
|
||||
@@ -56,3 +56,8 @@ class SetGraphActiveVersion(pydantic.BaseModel):
|
||||
|
||||
class UpdatePermissionsRequest(pydantic.BaseModel):
|
||||
permissions: List[APIKeyPermission]
|
||||
|
||||
|
||||
class RequestTopUp(pydantic.BaseModel):
|
||||
amount: int
|
||||
"""Amount of credits to top up."""
|
||||
|
||||
@@ -4,10 +4,11 @@ from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Sequence
|
||||
|
||||
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
|
||||
@@ -40,6 +41,7 @@ from backend.server.model import (
|
||||
CreateAPIKeyRequest,
|
||||
CreateAPIKeyResponse,
|
||||
CreateGraph,
|
||||
RequestTopUp,
|
||||
SetGraphActiveVersion,
|
||||
UpdatePermissionsRequest,
|
||||
)
|
||||
@@ -134,7 +136,54 @@ async def get_user_credits(
|
||||
user_id: Annotated[str, Depends(get_user_id)],
|
||||
) -> dict[str, int]:
|
||||
# Credits can go negative, so ensure it's at least 0 for user to see.
|
||||
return {"credits": max(await _user_credit_model.get_or_refill_credit(user_id), 0)}
|
||||
return {"credits": max(await _user_credit_model.get_credits(user_id), 0)}
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)]
|
||||
)
|
||||
async def request_top_up(
|
||||
request: RequestTopUp, user_id: Annotated[str, Depends(get_user_id)]
|
||||
):
|
||||
checkout_url = await _user_credit_model.top_up_intent(user_id, request.amount)
|
||||
return {"checkout_url": checkout_url}
|
||||
|
||||
|
||||
@v1_router.patch(
|
||||
path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)]
|
||||
)
|
||||
async def fulfill_checkout(user_id: Annotated[str, Depends(get_user_id)]):
|
||||
await _user_credit_model.fulfill_checkout(user_id=user_id)
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
if (
|
||||
event["type"] == "checkout.session.completed"
|
||||
or event["type"] == "checkout.session.async_payment_succeeded"
|
||||
):
|
||||
await _user_credit_model.fulfill_checkout(
|
||||
session_id=event["data"]["object"]["id"]
|
||||
)
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
########################################################
|
||||
|
||||
@@ -81,10 +81,14 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
|
||||
default=True,
|
||||
description="If authentication is enabled or not",
|
||||
)
|
||||
enable_credit: str = Field(
|
||||
default="false",
|
||||
enable_credit: bool = Field(
|
||||
default=False,
|
||||
description="If user credit system is enabled or not",
|
||||
)
|
||||
enable_beta_monthly_credit: bool = Field(
|
||||
default=True,
|
||||
description="If beta monthly credits accounting is enabled or not",
|
||||
)
|
||||
num_user_credits_refill: int = Field(
|
||||
default=1500,
|
||||
description="Number of credits to refill for each user",
|
||||
@@ -309,6 +313,9 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
|
||||
e2b_api_key: str = Field(default="", description="E2B API key")
|
||||
nvidia_api_key: str = Field(default="", description="Nvidia 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
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CreditTransaction" ADD COLUMN "runningBalance" INTEGER;
|
||||
18
autogpt_platform/backend/poetry.lock
generated
18
autogpt_platform/backend/poetry.lock
generated
@@ -3688,6 +3688,22 @@ docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"]
|
||||
release = ["twine"]
|
||||
test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"]
|
||||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "11.4.1"
|
||||
description = "Python bindings for the Stripe API"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "stripe-11.4.1-py2.py3-none-any.whl", hash = "sha256:8aa47a241de0355c383c916c4ef7273ab666f096a44ee7081e357db4a36f0cce"},
|
||||
{file = "stripe-11.4.1.tar.gz", hash = "sha256:7ddd251b622d490fe57d78487855dc9f4d95b1bb113607e81fd377037a133d5a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
|
||||
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
|
||||
|
||||
[[package]]
|
||||
name = "supabase"
|
||||
version = "2.11.0"
|
||||
@@ -4432,4 +4448,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
content-hash = "711669de9e6d5b81f19286bd41d52f57bc0177ba8ff5f2b477313a5b2d012ae5"
|
||||
content-hash = "341712d286b6a6fae89055bd21a55d8fa918973e446f6c0f0329a8493022cbae"
|
||||
|
||||
@@ -39,6 +39,7 @@ python-dotenv = "^1.0.1"
|
||||
redis = "^5.2.0"
|
||||
sentry-sdk = "2.19.2"
|
||||
strenum = "^0.4.9"
|
||||
stripe = "^11.3.0"
|
||||
supabase = "2.11.0"
|
||||
tenacity = "^9.0.0"
|
||||
tweepy = "^4.14.0"
|
||||
|
||||
@@ -393,6 +393,8 @@ model CreditTransaction {
|
||||
amount Int
|
||||
type CreditTransactionType
|
||||
|
||||
runningBalance Int?
|
||||
|
||||
isActive Boolean @default(true)
|
||||
metadata Json?
|
||||
|
||||
|
||||
@@ -4,22 +4,27 @@ import pytest
|
||||
from prisma.models import CreditTransaction
|
||||
|
||||
from backend.blocks.llm import AITextGeneratorBlock
|
||||
from backend.data.credit import UserCredit
|
||||
from backend.data.credit import BetaUserCredit
|
||||
from backend.data.user import DEFAULT_USER_ID
|
||||
from backend.integrations.credentials_store import openai_credentials
|
||||
from backend.util.test import SpinTestServer
|
||||
|
||||
REFILL_VALUE = 1000
|
||||
user_credit = UserCredit(REFILL_VALUE)
|
||||
user_credit = BetaUserCredit(REFILL_VALUE)
|
||||
|
||||
|
||||
async def disable_test_user_transactions():
|
||||
await CreditTransaction.prisma().delete_many(where={"userId": DEFAULT_USER_ID})
|
||||
|
||||
|
||||
@pytest.mark.asyncio(scope="session")
|
||||
async def test_block_credit_usage(server: SpinTestServer):
|
||||
current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
|
||||
await disable_test_user_transactions()
|
||||
await user_credit.top_up_credits(DEFAULT_USER_ID, 100)
|
||||
current_credit = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||
|
||||
spending_amount_1 = await user_credit.spend_credits(
|
||||
DEFAULT_USER_ID,
|
||||
current_credit,
|
||||
AITextGeneratorBlock().id,
|
||||
{
|
||||
"model": "gpt-4-turbo",
|
||||
@@ -31,68 +36,56 @@ async def test_block_credit_usage(server: SpinTestServer):
|
||||
},
|
||||
0.0,
|
||||
0.0,
|
||||
validate_balance=False,
|
||||
)
|
||||
assert spending_amount_1 > 0
|
||||
|
||||
spending_amount_2 = await user_credit.spend_credits(
|
||||
DEFAULT_USER_ID,
|
||||
current_credit,
|
||||
AITextGeneratorBlock().id,
|
||||
{"model": "gpt-4-turbo", "api_key": "owned_api_key"},
|
||||
0.0,
|
||||
0.0,
|
||||
validate_balance=False,
|
||||
)
|
||||
assert spending_amount_2 == 0
|
||||
|
||||
new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
|
||||
new_credit = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||
assert new_credit == current_credit - spending_amount_1 - spending_amount_2
|
||||
|
||||
|
||||
@pytest.mark.asyncio(scope="session")
|
||||
async def test_block_credit_top_up(server: SpinTestServer):
|
||||
current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
|
||||
await disable_test_user_transactions()
|
||||
current_credit = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||
|
||||
await user_credit.top_up_credits(DEFAULT_USER_ID, 100)
|
||||
|
||||
new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
|
||||
new_credit = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||
assert new_credit == current_credit + 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio(scope="session")
|
||||
async def test_block_credit_reset(server: SpinTestServer):
|
||||
month1 = datetime(2022, 1, 15)
|
||||
month2 = datetime(2022, 2, 15)
|
||||
await disable_test_user_transactions()
|
||||
month1 = 1
|
||||
month2 = 2
|
||||
|
||||
user_credit.time_now = lambda: month2
|
||||
month2credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
|
||||
# set the calendar to month 2 but use current time from now
|
||||
user_credit.time_now = lambda: datetime.now().replace(month=month2)
|
||||
month2credit = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||
|
||||
# Month 1 result should only affect month 1
|
||||
user_credit.time_now = lambda: month1
|
||||
month1credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
|
||||
user_credit.time_now = lambda: datetime.now().replace(month=month1)
|
||||
month1credit = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||
await user_credit.top_up_credits(DEFAULT_USER_ID, 100)
|
||||
assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month1credit + 100
|
||||
assert await user_credit.get_credits(DEFAULT_USER_ID) == month1credit + 100
|
||||
|
||||
# Month 2 balance is unaffected
|
||||
user_credit.time_now = lambda: month2
|
||||
assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month2credit
|
||||
user_credit.time_now = lambda: datetime.now().replace(month=month2)
|
||||
assert await user_credit.get_credits(DEFAULT_USER_ID) == month2credit
|
||||
|
||||
|
||||
@pytest.mark.asyncio(scope="session")
|
||||
async def test_credit_refill(server: SpinTestServer):
|
||||
# Clear all transactions within the month
|
||||
await CreditTransaction.prisma().update_many(
|
||||
where={
|
||||
"userId": DEFAULT_USER_ID,
|
||||
"createdAt": {
|
||||
"gte": datetime(2022, 2, 1),
|
||||
"lt": datetime(2022, 3, 1),
|
||||
},
|
||||
},
|
||||
data={"isActive": False},
|
||||
)
|
||||
user_credit.time_now = lambda: datetime(2022, 2, 15)
|
||||
|
||||
balance = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
|
||||
await disable_test_user_transactions()
|
||||
balance = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||
assert balance == REFILL_VALUE
|
||||
|
||||
@@ -5,6 +5,7 @@ NEXT_PUBLIC_AGPT_MARKETPLACE_URL=http://localhost:8015/api/v1/market
|
||||
NEXT_PUBLIC_LAUNCHDARKLY_ENABLED=false
|
||||
NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID=
|
||||
NEXT_PUBLIC_APP_ENV=dev
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
## Locale settings
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@sentry/nextjs": "^8",
|
||||
"@stripe/stripe-js": "^5.3.0",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.47.8",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/agptui/Button";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function CreditsPage() {
|
||||
const { credits, requestTopUp } = useCredits();
|
||||
const [amount, setAmount] = useState(5);
|
||||
const [patched, setPatched] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const topupStatus = searchParams.get("topup");
|
||||
const api = useBackendAPI();
|
||||
|
||||
useEffect(() => {
|
||||
if (!patched && topupStatus === "success") {
|
||||
api.fulfillCheckout();
|
||||
setPatched(true);
|
||||
}
|
||||
}, [api, patched, topupStatus]);
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-[800px] px-4 sm:px-8">
|
||||
<h1 className="font-circular mb-6 text-[28px] font-normal text-neutral-900 dark:text-neutral-100 sm:mb-8 sm:text-[35px]">
|
||||
Credits
|
||||
</h1>
|
||||
<p className="font-circular mb-6 text-base font-normal leading-tight text-neutral-600 dark:text-neutral-400">
|
||||
Current credits: <b>{credits}</b>
|
||||
</p>
|
||||
<h2 className="font-circular mb-4 text-lg font-normal leading-7 text-neutral-700 dark:text-neutral-300">
|
||||
Top-up Credits
|
||||
</h2>
|
||||
<p className="font-circular mb-6 text-base font-normal leading-tight text-neutral-600 dark:text-neutral-400">
|
||||
{topupStatus === "success" && (
|
||||
<span className="text-green-500">
|
||||
Your payment was successful. Your credits will be updated shortly.
|
||||
</span>
|
||||
)}
|
||||
{topupStatus === "cancel" && (
|
||||
<span className="text-red-500">
|
||||
Payment failed. Your payment method has not been charged.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="w-full">
|
||||
<label className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
1 USD = 100 credits, 5 USD is a minimum top-up
|
||||
</label>
|
||||
<div className="rounded-[55px] border border-slate-200 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<input
|
||||
type="number"
|
||||
name="displayName"
|
||||
value={amount}
|
||||
placeholder="Top-up amount in USD"
|
||||
min="5"
|
||||
step="1"
|
||||
className="font-circular w-full border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500"
|
||||
onChange={(e) => setAmount(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="font-circular mt-4 h-[50px] rounded-[35px] bg-neutral-800 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-neutral-900 dark:bg-neutral-200 dark:text-neutral-900 dark:hover:bg-neutral-100"
|
||||
onClick={() => requestTopUp(amount)}
|
||||
>
|
||||
{"Top-up"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,14 +33,14 @@ export default function Page({}: {}) {
|
||||
} catch (error) {
|
||||
console.error("Error fetching submissions:", error);
|
||||
}
|
||||
}, [api, supabase]);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabase) {
|
||||
return;
|
||||
}
|
||||
fetchData();
|
||||
}, [supabase]);
|
||||
}, [supabase, fetchData]);
|
||||
|
||||
const onEditSubmission = useCallback((submission: StoreSubmissionRequest) => {
|
||||
setSubmissionData(submission);
|
||||
@@ -56,7 +56,7 @@ export default function Page({}: {}) {
|
||||
api.deleteStoreSubmission(submission_id);
|
||||
fetchData();
|
||||
},
|
||||
[supabase],
|
||||
[api, supabase, fetchData],
|
||||
);
|
||||
|
||||
const onOpenPopout = useCallback(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
links: [
|
||||
{ text: "Creator Dashboard", href: "/store/dashboard" },
|
||||
{ text: "Agent dashboard", href: "/store/agent-dashboard" },
|
||||
{ text: "Credits", href: "/store/credits" },
|
||||
{ text: "Integrations", href: "/store/integrations" },
|
||||
{ text: "API Keys", href: "/store/api_keys" },
|
||||
{ text: "Profile", href: "/store/profile" },
|
||||
|
||||
@@ -61,7 +61,7 @@ function SearchResults({
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [searchTerm, sort]);
|
||||
}, [api, searchTerm, sort]);
|
||||
|
||||
const agentsCount = agents.length;
|
||||
const creatorsCount = creators.length;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconIntegrations,
|
||||
IconProfile,
|
||||
IconSliders,
|
||||
IconCoin,
|
||||
} from "../ui/icons";
|
||||
|
||||
interface SidebarLinkGroup {
|
||||
@@ -22,6 +23,10 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
|
||||
const stripeAvailable = Boolean(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet>
|
||||
@@ -49,6 +54,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
|
||||
Creator dashboard
|
||||
</div>
|
||||
</Link>
|
||||
{stripeAvailable && (
|
||||
<Link
|
||||
href="/store/credits"
|
||||
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
|
||||
>
|
||||
<IconCoin className="h-6 w-6" />
|
||||
<div className="p-ui-medium text-base font-medium leading-normal">
|
||||
Credits
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/store/integrations"
|
||||
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
|
||||
@@ -102,6 +118,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
|
||||
Agent dashboard
|
||||
</div>
|
||||
</Link>
|
||||
{stripeAvailable && (
|
||||
<Link
|
||||
href="/store/credits"
|
||||
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
|
||||
>
|
||||
<IconCoin className="h-6 w-6" />
|
||||
<div className="p-ui-medium text-base font-medium leading-normal">
|
||||
Credits
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/store/integrations"
|
||||
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconRefresh } from "@/components/ui/icons";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
|
||||
export default function CreditButton() {
|
||||
const [credit, setCredit] = useState<number | null>(null);
|
||||
const api = useBackendAPI();
|
||||
|
||||
const fetchCredit = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.getUserCredit();
|
||||
setCredit(response.credits);
|
||||
} catch (error) {
|
||||
console.error("Error fetching credit:", error);
|
||||
setCredit(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredit();
|
||||
}, [fetchCredit]);
|
||||
const { credits, fetchCredits } = useCredits();
|
||||
|
||||
return (
|
||||
credit !== null && (
|
||||
credits !== null && (
|
||||
<Button
|
||||
onClick={fetchCredit}
|
||||
onClick={fetchCredits}
|
||||
variant="outline"
|
||||
className="flex items-center space-x-2 rounded-xl bg-gray-200"
|
||||
>
|
||||
<span className="mr-2 flex items-center text-foreground">
|
||||
{credit} <span className="ml-2 text-muted-foreground"> credits</span>
|
||||
{credits} <span className="ml-2 text-muted-foreground"> credits</span>
|
||||
</span>
|
||||
<IconRefresh />
|
||||
</Button>
|
||||
|
||||
@@ -323,7 +323,7 @@ export const IconCoin = createIcon((props) => (
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-label="Coin Icon"
|
||||
|
||||
@@ -874,7 +874,7 @@ export default function useAgentGraph(
|
||||
request: "save",
|
||||
state: "saving",
|
||||
});
|
||||
}, [saveAgent]);
|
||||
}, [saveAgent, saveRunRequest.state]);
|
||||
|
||||
const requestSaveAndRun = useCallback(() => {
|
||||
saveAgent();
|
||||
|
||||
48
autogpt_platform/frontend/src/hooks/useCredits.ts
Normal file
48
autogpt_platform/frontend/src/hooks/useCredits.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
|
||||
export default function useCredits(): {
|
||||
credits: number | null;
|
||||
fetchCredits: () => void;
|
||||
requestTopUp: (usd_amount: number) => Promise<void>;
|
||||
} {
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const api = useMemo(() => new AutoGPTServerAPI(), []);
|
||||
const router = useRouter();
|
||||
|
||||
const fetchCredits = useCallback(async () => {
|
||||
const response = await api.getUserCredit();
|
||||
setCredits(response.credits);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredits();
|
||||
}, [fetchCredits]);
|
||||
|
||||
const requestTopUp = useCallback(
|
||||
async (usd_amount: number) => {
|
||||
const stripe = await stripePromise;
|
||||
|
||||
if (!stripe) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert dollar amount to credit count
|
||||
const response = await api.requestTopUp(usd_amount * 100);
|
||||
router.push(response.checkout_url);
|
||||
},
|
||||
[api, router],
|
||||
);
|
||||
|
||||
return {
|
||||
credits,
|
||||
fetchCredits,
|
||||
requestTopUp,
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,14 @@ export default class BackendAPI {
|
||||
}
|
||||
}
|
||||
|
||||
requestTopUp(amount: number): Promise<{ checkout_url: string }> {
|
||||
return this._request("POST", "/credits", { amount });
|
||||
}
|
||||
|
||||
fulfillCheckout(): Promise<void> {
|
||||
return this._request("PATCH", "/credits");
|
||||
}
|
||||
|
||||
getBlocks(): Promise<Block[]> {
|
||||
return this._get("/blocks");
|
||||
}
|
||||
|
||||
@@ -3257,6 +3257,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5"
|
||||
integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw==
|
||||
|
||||
"@stripe/stripe-js@^5.3.0":
|
||||
version "5.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.4.0.tgz#847e870ddfe9283432526867857a4c1fba9b11ed"
|
||||
integrity sha512-3tfMbSvLGB+OsJ2MsjWjWo+7sp29dwx+3+9kG/TEnZQJt+EwbF/Nomm43cSK+6oXZA9uhspgyrB+BbrPRumx4g==
|
||||
|
||||
"@supabase/auth-js@2.67.3":
|
||||
version "2.67.3"
|
||||
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.3.tgz#a1f5eb22440b0cdbf87fe2ecae662a8dd8bb2028"
|
||||
|
||||
Reference in New Issue
Block a user