mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(platform): Add Dispute & Refund resolution process (#9466)
### Changes 🏗️ Added the dispute & refund handling on the system. https://github.com/user-attachments/assets/2d9c7b48-2ee1-401d-be65-5e9787c8130e ### Checklist 📋 #### For code changes: - [ ] 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: - [ ] `.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>
This commit is contained in:
@@ -5,18 +5,24 @@ from datetime import datetime, timezone
|
||||
|
||||
import stripe
|
||||
from prisma import Json
|
||||
from prisma.enums import CreditTransactionType
|
||||
from prisma.enums import CreditRefundRequestStatus, CreditTransactionType
|
||||
from prisma.errors import UniqueViolationError
|
||||
from prisma.models import CreditTransaction, User
|
||||
from prisma.models import CreditRefundRequest, CreditTransaction, User
|
||||
from prisma.types import CreditTransactionCreateInput, CreditTransactionWhereInput
|
||||
from pydantic import BaseModel
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
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.data.execution import NodeExecutionEntry
|
||||
from backend.data.model import AutoTopUpConfig, TransactionHistory, UserTransaction
|
||||
from backend.data.model import (
|
||||
AutoTopUpConfig,
|
||||
RefundRequest,
|
||||
TransactionHistory,
|
||||
UserTransaction,
|
||||
)
|
||||
from backend.data.user import get_user_by_id
|
||||
from backend.util.settings import Settings
|
||||
|
||||
@@ -40,22 +46,37 @@ class UserCreditBase(ABC):
|
||||
async def get_transaction_history(
|
||||
self,
|
||||
user_id: str,
|
||||
transaction_time: datetime,
|
||||
transaction_count_limit: int,
|
||||
transaction_time_ceiling: datetime | None = None,
|
||||
transaction_type: str | None = None,
|
||||
) -> TransactionHistory:
|
||||
"""
|
||||
Get the credit transactions for the user.
|
||||
|
||||
Args:
|
||||
user_id (str): The user ID.
|
||||
transaction_time (datetime): The upper bound of the transaction time.
|
||||
transaction_count_limit (int): The transaction count limit.
|
||||
transaction_time_ceiling (datetime): The upper bound of the transaction time.
|
||||
transaction_type (str): The transaction type filter.
|
||||
|
||||
Returns:
|
||||
TransactionHistory: The credit transactions for the user.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_refund_requests(self, user_id: str) -> list[RefundRequest]:
|
||||
"""
|
||||
Get the refund requests for the user.
|
||||
|
||||
Args:
|
||||
user_id (str): The user ID.
|
||||
|
||||
Returns:
|
||||
list[RefundRequest]: The refund requests for the user.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def spend_credits(
|
||||
self,
|
||||
@@ -79,7 +100,7 @@ class UserCreditBase(ABC):
|
||||
@abstractmethod
|
||||
async def top_up_credits(self, user_id: str, amount: int):
|
||||
"""
|
||||
Top up the credits for the user immediately.
|
||||
Top up the credits for the user.
|
||||
|
||||
Args:
|
||||
user_id (str): The user ID.
|
||||
@@ -101,6 +122,46 @@ class UserCreditBase(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def top_up_refund(
|
||||
self, user_id: str, transaction_key: str, metadata: dict[str, str]
|
||||
) -> int:
|
||||
"""
|
||||
Refund the top-up transaction for the user.
|
||||
|
||||
Args:
|
||||
user_id (str): The user ID.
|
||||
transaction_key (str): The top-up transaction key to refund.
|
||||
metadata (dict[str, str]): The metadata of the refund.
|
||||
|
||||
Returns:
|
||||
int: The amount refunded.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def deduct_credits(
|
||||
self,
|
||||
request: stripe.Refund | stripe.Dispute,
|
||||
):
|
||||
"""
|
||||
Deduct the credits for the user based on the dispute or refund of the top-up.
|
||||
|
||||
Args:
|
||||
request (stripe.Refund | stripe.Dispute): The refund or dispute request.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def handle_dispute(self, dispute: stripe.Dispute):
|
||||
"""
|
||||
Handle the dispute for the user based on the dispute request.
|
||||
|
||||
Args:
|
||||
dispute (stripe.Dispute): The dispute request.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fulfill_checkout(
|
||||
self, *, session_id: str | None = None, user_id: str | None = None
|
||||
@@ -167,8 +228,17 @@ class UserCreditBase(ABC):
|
||||
)
|
||||
return transaction_balance, transaction_time
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||
reraise=True,
|
||||
)
|
||||
async def _enable_transaction(
|
||||
self, transaction_key: str, user_id: str, metadata: Json
|
||||
self,
|
||||
transaction_key: str,
|
||||
user_id: str,
|
||||
metadata: Json,
|
||||
new_transaction_key: str | None = None,
|
||||
):
|
||||
|
||||
transaction = await CreditTransaction.prisma().find_first_or_raise(
|
||||
@@ -188,6 +258,7 @@ class UserCreditBase(ABC):
|
||||
}
|
||||
},
|
||||
data={
|
||||
"transactionKey": new_transaction_key or transaction_key,
|
||||
"isActive": True,
|
||||
"runningBalance": user_balance + transaction.amount,
|
||||
"createdAt": self.time_now(),
|
||||
@@ -203,6 +274,7 @@ class UserCreditBase(ABC):
|
||||
is_active: bool = True,
|
||||
transaction_key: str | None = None,
|
||||
ceiling_balance: int | None = None,
|
||||
fail_insufficient_credits: bool = True,
|
||||
metadata: Json = Json({}),
|
||||
) -> tuple[int, str]:
|
||||
"""
|
||||
@@ -216,6 +288,7 @@ class UserCreditBase(ABC):
|
||||
is_active (bool): Whether the transaction is active or needs to be manually activated through _enable_transaction.
|
||||
transaction_key (str | None): The transaction key. Avoids adding transaction if the key already exists.
|
||||
ceiling_balance (int | None): The ceiling balance. Avoids adding more credits if the balance is already above the ceiling.
|
||||
fail_insufficient_credits (bool): Whether to fail if the user has insufficient credits.
|
||||
metadata (Json): The metadata of the transaction.
|
||||
|
||||
Returns:
|
||||
@@ -225,15 +298,17 @@ class UserCreditBase(ABC):
|
||||
# Get latest balance snapshot
|
||||
user_balance, _ = await self._get_credits(user_id)
|
||||
|
||||
if ceiling_balance and user_balance >= ceiling_balance:
|
||||
if ceiling_balance and amount > 0 and user_balance >= ceiling_balance:
|
||||
raise ValueError(
|
||||
f"You already have enough balance for user {user_id}, balance: {user_balance}, ceiling: {ceiling_balance}"
|
||||
f"You already have enough balance of ${user_balance/100}, top-up is not required when you already have at least ${ceiling_balance/100}"
|
||||
)
|
||||
|
||||
if amount < 0 and user_balance < abs(amount):
|
||||
raise ValueError(
|
||||
f"Insufficient balance of ${user_balance/100} to run the block that costs ${abs(amount)/100}"
|
||||
)
|
||||
if amount < 0 and user_balance + amount < 0:
|
||||
if fail_insufficient_credits:
|
||||
raise ValueError(
|
||||
f"Insufficient balance of ${user_balance/100}, where this will cost ${abs(amount)/100}"
|
||||
)
|
||||
amount = min(-user_balance, 0)
|
||||
|
||||
# Create the transaction
|
||||
transaction_data: CreditTransactionCreateInput = {
|
||||
@@ -369,6 +444,153 @@ class UserCredit(UserCreditBase):
|
||||
async def top_up_credits(self, user_id: str, amount: int):
|
||||
await self._top_up_credits(user_id, amount)
|
||||
|
||||
async def top_up_refund(
|
||||
self, user_id: str, transaction_key: str, metadata: dict[str, str]
|
||||
) -> int:
|
||||
transaction = await CreditTransaction.prisma().find_first_or_raise(
|
||||
where={
|
||||
"transactionKey": transaction_key,
|
||||
"userId": user_id,
|
||||
"isActive": True,
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
}
|
||||
)
|
||||
balance = await self.get_credits(user_id)
|
||||
amount = transaction.amount
|
||||
refund_key = f"{transaction.createdAt.strftime('%Y-%W')}-{user_id}"
|
||||
|
||||
try:
|
||||
await CreditRefundRequest.prisma().create(
|
||||
data={
|
||||
"id": refund_key,
|
||||
"transactionKey": transaction_key,
|
||||
"userId": user_id,
|
||||
"amount": amount,
|
||||
"reason": metadata.get("reason", ""),
|
||||
"status": CreditRefundRequestStatus.PENDING,
|
||||
"result": "The refund request is under review.",
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
raise ValueError(
|
||||
"Unable to request a refund for this transaction, the request of the top-up transaction within the same week has already been made."
|
||||
)
|
||||
|
||||
if amount - balance > settings.config.refund_credit_tolerance_threshold:
|
||||
# TODO: add a notification for the platform administrator.
|
||||
return 0 # Register the refund request for manual approval.
|
||||
|
||||
# Auto refund the top-up.
|
||||
refund = stripe.Refund.create(payment_intent=transaction_key, metadata=metadata)
|
||||
return refund.amount
|
||||
|
||||
async def deduct_credits(self, request: stripe.Refund | stripe.Dispute):
|
||||
if isinstance(request, stripe.Refund) and request.status != "succeeded":
|
||||
logger.warning(
|
||||
f"Skip processing refund #{request.id} with status {request.status}"
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(request, stripe.Dispute) and request.status != "lost":
|
||||
logger.warning(
|
||||
f"Skip processing dispute #{request.id} with status {request.status}"
|
||||
)
|
||||
return
|
||||
|
||||
transaction = await CreditTransaction.prisma().find_first_or_raise(
|
||||
where={
|
||||
"transactionKey": str(request.payment_intent),
|
||||
"isActive": True,
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
}
|
||||
)
|
||||
if request.amount <= 0 or request.amount > transaction.amount:
|
||||
raise AssertionError(
|
||||
f"Invalid amount to deduct ${request.amount/100} from ${transaction.amount/100} top-up"
|
||||
)
|
||||
|
||||
await self._add_transaction(
|
||||
user_id=transaction.userId,
|
||||
amount=-request.amount,
|
||||
transaction_type=CreditTransactionType.REFUND,
|
||||
transaction_key=request.id,
|
||||
metadata=Json(request),
|
||||
fail_insufficient_credits=False,
|
||||
)
|
||||
|
||||
# Update the result of the refund request if it exists.
|
||||
await CreditRefundRequest.prisma().update_many(
|
||||
where={
|
||||
"userId": transaction.userId,
|
||||
"transactionKey": transaction.transactionKey,
|
||||
},
|
||||
data={
|
||||
"amount": request.amount,
|
||||
"status": CreditRefundRequestStatus.APPROVED,
|
||||
"result": "The refund request has been approved, the amount will be credited back to your account.",
|
||||
},
|
||||
)
|
||||
|
||||
async def handle_dispute(self, dispute: stripe.Dispute):
|
||||
transaction = await CreditTransaction.prisma().find_first_or_raise(
|
||||
where={
|
||||
"transactionKey": str(dispute.payment_intent),
|
||||
"isActive": True,
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
}
|
||||
)
|
||||
user_id = transaction.userId
|
||||
amount = dispute.amount
|
||||
balance = await self.get_credits(user_id)
|
||||
|
||||
# If the user has enough balance, just let them win the dispute.
|
||||
if balance - amount >= settings.config.refund_credit_tolerance_threshold:
|
||||
logger.warning(f"Accepting dispute from {user_id} for ${amount/100}")
|
||||
dispute.close()
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
f"Adding extra info for dispute from {user_id} for ${amount/100}"
|
||||
)
|
||||
# Retrieve recent transaction history to support our evidence.
|
||||
# This provides a concise timeline that shows service usage and proper credit application.
|
||||
transaction_history = await self.get_transaction_history(
|
||||
user_id, transaction_count_limit=None
|
||||
)
|
||||
user = await get_user_by_id(user_id)
|
||||
|
||||
# Build a comprehensive explanation message that includes:
|
||||
# - Confirmation that the top-up transaction was processed and credits were applied.
|
||||
# - A summary of recent transaction history.
|
||||
# - An explanation that the funds were used to render the agreed service.
|
||||
evidence_text = (
|
||||
f"The top-up transaction of ${transaction.amount / 100:.2f} was processed successfully, and the corresponding credits "
|
||||
"were applied to the user’s account. Our records confirm that the funds were utilized for the intended services. "
|
||||
"Below is a summary of recent transaction activity:\n"
|
||||
)
|
||||
for tx in transaction_history.transactions:
|
||||
if tx.transaction_key == transaction.transactionKey:
|
||||
additional_comment = (
|
||||
" [This top-up transaction is the subject of the dispute]."
|
||||
)
|
||||
else:
|
||||
additional_comment = ""
|
||||
|
||||
evidence_text += (
|
||||
f"- {tx.description}: Amount ${tx.amount / 100:.2f} on {tx.transaction_time.isoformat()}, "
|
||||
f"resulting balance ${tx.balance / 100:.2f} {additional_comment}\n"
|
||||
)
|
||||
evidence_text += (
|
||||
"\nThis evidence demonstrates that the transaction was authorized and that the charged amount was used to render the service as agreed."
|
||||
"\nAdditionally, we provide an automated refund functionality, so the user could have used it if they were not satisfied with the service. "
|
||||
)
|
||||
evidence: stripe.Dispute.ModifyParamsEvidence = {
|
||||
"product_description": "AutoGPT Platform Credits",
|
||||
"customer_email_address": user.email,
|
||||
"uncategorized_text": evidence_text[:20000],
|
||||
}
|
||||
stripe.Dispute.modify(dispute.id, evidence=evidence)
|
||||
|
||||
async def _top_up_credits(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -386,10 +608,15 @@ class UserCredit(UserCreditBase):
|
||||
):
|
||||
raise ValueError(f"Transaction key {key} already exists for user {user_id}")
|
||||
|
||||
if amount == 0:
|
||||
transaction_type = CreditTransactionType.CARD_CHECK
|
||||
else:
|
||||
transaction_type = CreditTransactionType.TOP_UP
|
||||
|
||||
_, transaction_key = await self._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
transaction_type=transaction_type,
|
||||
is_active=False,
|
||||
transaction_key=key,
|
||||
ceiling_balance=ceiling_balance,
|
||||
@@ -401,8 +628,10 @@ class UserCredit(UserCreditBase):
|
||||
if not payment_methods:
|
||||
raise ValueError("No payment method found, please add it on the platform.")
|
||||
|
||||
successful_transaction = None
|
||||
new_transaction_key = None
|
||||
for payment_method in payment_methods:
|
||||
if amount == 0:
|
||||
if transaction_type == CreditTransactionType.CARD_CHECK:
|
||||
setup_intent = stripe.SetupIntent.create(
|
||||
customer=customer_id,
|
||||
usage="off_session",
|
||||
@@ -414,8 +643,9 @@ class UserCredit(UserCreditBase):
|
||||
},
|
||||
)
|
||||
if setup_intent.status == "succeeded":
|
||||
return
|
||||
|
||||
successful_transaction = Json({"setup_intent": setup_intent})
|
||||
new_transaction_key = setup_intent.id
|
||||
break
|
||||
else:
|
||||
payment_intent = stripe.PaymentIntent.create(
|
||||
amount=amount,
|
||||
@@ -431,15 +661,20 @@ class UserCredit(UserCreditBase):
|
||||
},
|
||||
)
|
||||
if payment_intent.status == "succeeded":
|
||||
await self._enable_transaction(
|
||||
transaction_key=transaction_key,
|
||||
user_id=user_id,
|
||||
metadata=Json({"payment_intent": payment_intent}),
|
||||
)
|
||||
return
|
||||
successful_transaction = Json({"payment_intent": payment_intent})
|
||||
new_transaction_key = payment_intent.id
|
||||
break
|
||||
|
||||
raise ValueError(
|
||||
f"Out of {len(payment_methods)} payment methods tried, none is supported"
|
||||
if not successful_transaction:
|
||||
raise ValueError(
|
||||
f"Out of {len(payment_methods)} payment methods tried, none is supported"
|
||||
)
|
||||
|
||||
await self._enable_transaction(
|
||||
transaction_key=transaction_key,
|
||||
new_transaction_key=new_transaction_key,
|
||||
user_id=user_id,
|
||||
metadata=successful_transaction,
|
||||
)
|
||||
|
||||
async def top_up_intent(self, user_id: str, amount: int) -> str:
|
||||
@@ -471,9 +706,9 @@ class UserCredit(UserCreditBase):
|
||||
payment_intent_data={"setup_future_usage": "off_session"},
|
||||
saved_payment_method_options={"payment_method_save": "enabled"},
|
||||
success_url=settings.config.frontend_base_url
|
||||
+ "/marketplace/credits?topup=success",
|
||||
+ "/profile/credits?topup=success",
|
||||
cancel_url=settings.config.frontend_base_url
|
||||
+ "/marketplace/credits?topup=cancel",
|
||||
+ "/profile/credits?topup=cancel",
|
||||
allow_promotion_codes=True,
|
||||
)
|
||||
|
||||
@@ -483,7 +718,7 @@ class UserCredit(UserCreditBase):
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
transaction_key=checkout_session.id,
|
||||
is_active=False,
|
||||
metadata=Json({"checkout_session": checkout_session}),
|
||||
metadata=Json(checkout_session),
|
||||
)
|
||||
|
||||
return checkout_session.url or ""
|
||||
@@ -499,6 +734,7 @@ class UserCredit(UserCreditBase):
|
||||
find_filter: CreditTransactionWhereInput = {
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
"isActive": False,
|
||||
"amount": {"gt": 0},
|
||||
}
|
||||
if session_id:
|
||||
find_filter["transactionKey"] = session_id
|
||||
@@ -515,18 +751,25 @@ class UserCredit(UserCreditBase):
|
||||
if not credit_transaction:
|
||||
return
|
||||
|
||||
# If the transaction is not a checkout session, then skip the fulfillment
|
||||
if not credit_transaction.transactionKey.startswith("cs_"):
|
||||
return
|
||||
|
||||
# Retrieve the Checkout Session from the API
|
||||
checkout_session = stripe.checkout.Session.retrieve(
|
||||
credit_transaction.transactionKey
|
||||
credit_transaction.transactionKey,
|
||||
expand=["payment_intent"],
|
||||
)
|
||||
|
||||
# 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"]:
|
||||
assert isinstance(checkout_session.payment_intent, stripe.PaymentIntent)
|
||||
await self._enable_transaction(
|
||||
transaction_key=credit_transaction.transactionKey,
|
||||
new_transaction_key=checkout_session.payment_intent.id,
|
||||
user_id=credit_transaction.userId,
|
||||
metadata=Json({"checkout_session": checkout_session}),
|
||||
metadata=Json(checkout_session),
|
||||
)
|
||||
|
||||
async def get_credits(self, user_id: str) -> int:
|
||||
@@ -536,15 +779,24 @@ class UserCredit(UserCreditBase):
|
||||
async def get_transaction_history(
|
||||
self,
|
||||
user_id: str,
|
||||
transaction_time: datetime,
|
||||
transaction_count_limit: int,
|
||||
transaction_count_limit: int | None = 100,
|
||||
transaction_time_ceiling: datetime | None = None,
|
||||
transaction_type: str | None = None,
|
||||
) -> TransactionHistory:
|
||||
|
||||
transactions_filter: CreditTransactionWhereInput = {
|
||||
"userId": user_id,
|
||||
"isActive": True,
|
||||
}
|
||||
if transaction_time_ceiling:
|
||||
transaction_time_ceiling = transaction_time_ceiling.replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
transactions_filter["createdAt"] = {"lt": transaction_time_ceiling}
|
||||
if transaction_type:
|
||||
transactions_filter["type"] = CreditTransactionType[transaction_type]
|
||||
transactions = await CreditTransaction.prisma().find_many(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"createdAt": {"lt": transaction_time},
|
||||
"isActive": True,
|
||||
},
|
||||
where=transactions_filter,
|
||||
order={"createdAt": "desc"},
|
||||
take=transaction_count_limit,
|
||||
)
|
||||
@@ -559,7 +811,7 @@ class UserCredit(UserCreditBase):
|
||||
if t.metadata
|
||||
else UsageTransactionMetadata()
|
||||
)
|
||||
tx_time = t.createdAt.replace(tzinfo=None)
|
||||
tx_time = t.createdAt.replace(tzinfo=timezone.utc)
|
||||
|
||||
if t.type == CreditTransactionType.USAGE and metadata.graph_exec_id:
|
||||
gt = grouped_transactions[metadata.graph_exec_id]
|
||||
@@ -573,6 +825,7 @@ class UserCredit(UserCreditBase):
|
||||
else:
|
||||
gt = grouped_transactions[t.transactionKey]
|
||||
gt.description = f"{t.type} Transaction"
|
||||
gt.transaction_key = t.transactionKey
|
||||
|
||||
gt.amount += t.amount
|
||||
gt.transaction_type = t.type
|
||||
@@ -588,6 +841,25 @@ class UserCredit(UserCreditBase):
|
||||
),
|
||||
)
|
||||
|
||||
async def get_refund_requests(self, user_id: str) -> list[RefundRequest]:
|
||||
return [
|
||||
RefundRequest(
|
||||
id=r.id,
|
||||
user_id=r.userId,
|
||||
transaction_key=r.transactionKey,
|
||||
amount=r.amount,
|
||||
reason=r.reason,
|
||||
result=r.result,
|
||||
status=r.status,
|
||||
created_at=r.createdAt,
|
||||
updated_at=r.updatedAt,
|
||||
)
|
||||
for r in await CreditRefundRequest.prisma().find_many(
|
||||
where={"userId": user_id},
|
||||
order={"createdAt": "desc"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class BetaUserCredit(UserCredit):
|
||||
"""
|
||||
@@ -608,7 +880,7 @@ class BetaUserCredit(UserCredit):
|
||||
balance, _ = await self._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=max(self.num_user_credits_refill - balance, 0),
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
transaction_type=CreditTransactionType.GRANT,
|
||||
transaction_key=f"MONTHLY-CREDIT-TOP-UP-{cur_time}",
|
||||
)
|
||||
return balance
|
||||
@@ -624,6 +896,9 @@ class DisabledUserCredit(UserCreditBase):
|
||||
async def get_transaction_history(self, *args, **kwargs) -> TransactionHistory:
|
||||
return TransactionHistory(transactions=[], next_transaction_time=None)
|
||||
|
||||
async def get_refund_requests(self, *args, **kwargs) -> list[RefundRequest]:
|
||||
return []
|
||||
|
||||
async def spend_credits(self, *args, **kwargs) -> int:
|
||||
return 0
|
||||
|
||||
@@ -633,6 +908,15 @@ class DisabledUserCredit(UserCreditBase):
|
||||
async def top_up_intent(self, *args, **kwargs) -> str:
|
||||
return ""
|
||||
|
||||
async def top_up_refund(self, *args, **kwargs) -> int:
|
||||
return 0
|
||||
|
||||
async def deduct_credits(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def handle_dispute(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def fulfill_checkout(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@@ -657,7 +941,11 @@ async def get_stripe_customer_id(user_id: str) -> str:
|
||||
if user.stripeCustomerId:
|
||||
return user.stripeCustomerId
|
||||
|
||||
customer = stripe.Customer.create(name=user.name or "", email=user.email)
|
||||
customer = stripe.Customer.create(
|
||||
name=user.name or "",
|
||||
email=user.email,
|
||||
metadata={"user_id": user_id},
|
||||
)
|
||||
await User.prisma().update(
|
||||
where={"id": user_id}, data={"stripeCustomerId": customer.id}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
@@ -375,7 +375,8 @@ class AutoTopUpConfig(BaseModel):
|
||||
|
||||
|
||||
class UserTransaction(BaseModel):
|
||||
transaction_time: datetime = datetime.min
|
||||
transaction_key: str = ""
|
||||
transaction_time: datetime = datetime.min.replace(tzinfo=timezone.utc)
|
||||
transaction_type: CreditTransactionType = CreditTransactionType.USAGE
|
||||
amount: int = 0
|
||||
balance: int = 0
|
||||
@@ -383,9 +384,21 @@ class UserTransaction(BaseModel):
|
||||
usage_graph_id: str | None = None
|
||||
usage_execution_id: str | None = None
|
||||
usage_node_count: int = 0
|
||||
usage_start_time: datetime = datetime.max
|
||||
usage_start_time: datetime = datetime.max.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class TransactionHistory(BaseModel):
|
||||
transactions: list[UserTransaction]
|
||||
next_transaction_time: datetime | None
|
||||
|
||||
|
||||
class RefundRequest(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
transaction_key: str
|
||||
amount: int
|
||||
reason: str
|
||||
result: str | None = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -33,6 +33,7 @@ from backend.data.api_key import (
|
||||
from backend.data.block import BlockInput, CompletedBlockOutput
|
||||
from backend.data.credit import (
|
||||
AutoTopUpConfig,
|
||||
RefundRequest,
|
||||
TransactionHistory,
|
||||
get_auto_top_up,
|
||||
get_block_costs,
|
||||
@@ -145,8 +146,7 @@ def execute_graph_block(block_id: str, data: BlockInput) -> CompletedBlockOutput
|
||||
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_credits(user_id), 0)}
|
||||
return {"credits": await _user_credit_model.get_credits(user_id)}
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
@@ -161,6 +161,19 @@ async def request_top_up(
|
||||
return {"checkout_url": checkout_url}
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
path="/credits/{transaction_key}/refund",
|
||||
tags=["credits"],
|
||||
dependencies=[Depends(auth_middleware)],
|
||||
)
|
||||
async def refund_top_up(
|
||||
user_id: Annotated[str, Depends(get_user_id)],
|
||||
transaction_key: str,
|
||||
metadata: dict[str, str],
|
||||
) -> int:
|
||||
return await _user_credit_model.top_up_refund(user_id, transaction_key, metadata)
|
||||
|
||||
|
||||
@v1_router.patch(
|
||||
path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)]
|
||||
)
|
||||
@@ -234,6 +247,12 @@ async def stripe_webhook(request: Request):
|
||||
session_id=event["data"]["object"]["id"]
|
||||
)
|
||||
|
||||
if event["type"] == "charge.dispute.created":
|
||||
await _user_credit_model.handle_dispute(event["data"]["object"])
|
||||
|
||||
if event["type"] == "refund.created" or event["type"] == "charge.dispute.closed":
|
||||
await _user_credit_model.deduct_credits(event["data"]["object"])
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
@@ -243,7 +262,7 @@ async def manage_payment_method(
|
||||
) -> dict[str, str]:
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=await get_stripe_customer_id(user_id),
|
||||
return_url=settings.config.frontend_base_url + "/marketplace/credits",
|
||||
return_url=settings.config.frontend_base_url + "/profile/credits",
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
@@ -256,6 +275,7 @@ async def manage_payment_method(
|
||||
async def get_credit_history(
|
||||
user_id: Annotated[str, Depends(get_user_id)],
|
||||
transaction_time: datetime | None = None,
|
||||
transaction_type: str | None = None,
|
||||
transaction_count_limit: int = 100,
|
||||
) -> TransactionHistory:
|
||||
if transaction_count_limit < 1 or transaction_count_limit > 1000:
|
||||
@@ -263,11 +283,19 @@ async def get_credit_history(
|
||||
|
||||
return await _user_credit_model.get_transaction_history(
|
||||
user_id=user_id,
|
||||
transaction_time=transaction_time or datetime.max,
|
||||
transaction_time_ceiling=transaction_time,
|
||||
transaction_count_limit=transaction_count_limit,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
|
||||
@v1_router.get(path="/credits/refunds", dependencies=[Depends(auth_middleware)])
|
||||
async def get_refund_requests(
|
||||
user_id: Annotated[str, Depends(get_user_id)]
|
||||
) -> list[RefundRequest]:
|
||||
return await _user_credit_model.get_refund_requests(user_id)
|
||||
|
||||
|
||||
########################################################
|
||||
##################### Graphs ###########################
|
||||
########################################################
|
||||
|
||||
@@ -30,5 +30,8 @@ class JSONCryptor:
|
||||
"""Decrypt string to dictionary"""
|
||||
if not encrypted_str:
|
||||
return {}
|
||||
decrypted = self.fernet.decrypt(encrypted_str.encode())
|
||||
return json.loads(decrypted.decode())
|
||||
try:
|
||||
decrypted = self.fernet.decrypt(encrypted_str.encode())
|
||||
return json.loads(decrypted.decode())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -93,7 +93,10 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
|
||||
default=1500,
|
||||
description="Number of credits to refill for each user",
|
||||
)
|
||||
# Add more configuration fields as needed
|
||||
refund_credit_tolerance_threshold: int = Field(
|
||||
default=500,
|
||||
description="Maximum number of credits above the balance to be auto-approved.",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CreditRefundRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CreditRefundRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"transactionKey" TEXT NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"result" TEXT,
|
||||
"status" "CreditRefundRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
|
||||
CONSTRAINT "CreditRefundRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CreditRefundRequest_userId_transactionKey_idx" ON "CreditRefundRequest"("userId", "transactionKey");
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "CreditTransactionType" ADD VALUE 'GRANT';
|
||||
ALTER TYPE "CreditTransactionType" ADD VALUE 'REFUND';
|
||||
ALTER TYPE "CreditTransactionType" ADD VALUE 'CARD_CHECK';
|
||||
@@ -422,16 +422,20 @@ model AnalyticsMetrics {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum CreditTransactionType {
|
||||
TOP_UP
|
||||
USAGE
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
//////// ACCOUNTING AND CREDIT SYSTEM TABLES //////////
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
enum CreditTransactionType {
|
||||
TOP_UP
|
||||
USAGE
|
||||
GRANT
|
||||
REFUND
|
||||
CARD_CHECK
|
||||
}
|
||||
|
||||
model CreditTransaction {
|
||||
transactionKey String @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -451,6 +455,28 @@ model CreditTransaction {
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
enum CreditRefundRequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model CreditRefundRequest {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
userId String
|
||||
transactionKey String
|
||||
|
||||
amount Int
|
||||
reason String
|
||||
result String?
|
||||
status CreditRefundRequestStatus @default(PENDING)
|
||||
|
||||
@@index([userId, transactionKey])
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////// Store TABLES ///////////////////////////
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import { CreditTransaction } from "@/lib/autogpt-server-api";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface RefundModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
transactions: CreditTransaction[];
|
||||
formatCredits: (credit: number) => string;
|
||||
refundCredits: (transaction_key: string, reason: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const RefundModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
transactions,
|
||||
formatCredits,
|
||||
refundCredits,
|
||||
}: RefundModalProps) => {
|
||||
const [selectedTransactionId, setSelectedTransactionId] =
|
||||
useState<string>("");
|
||||
const [refundReason, setRefundReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedTransactionId("");
|
||||
setRefundReason("");
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRefundRequest = () => {
|
||||
setError(null);
|
||||
|
||||
const selectedTransaction = transactions.find(
|
||||
(t) => t.transaction_key === selectedTransactionId,
|
||||
);
|
||||
|
||||
if (!selectedTransaction) {
|
||||
setError("Please select a transaction to refund");
|
||||
return;
|
||||
}
|
||||
|
||||
if (refundReason.trim().length < 20) {
|
||||
setError("Please provide a clear reason for the refund");
|
||||
return;
|
||||
}
|
||||
|
||||
refundCredits(selectedTransactionId, refundReason).finally(() =>
|
||||
handleClose(),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Request Refund</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
No eligible transactions found for refund.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Select Transaction
|
||||
</label>
|
||||
<Select
|
||||
value={selectedTransactionId}
|
||||
onValueChange={setSelectedTransactionId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a transaction" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{transactions.map((transaction) => (
|
||||
<SelectItem
|
||||
key={transaction.transaction_key}
|
||||
value={transaction.transaction_key}
|
||||
>
|
||||
{new Date(transaction.transaction_time).toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
- {formatCredits(transaction.amount)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="refundReason" className="text-sm font-medium">
|
||||
Reason for Refund
|
||||
</label>
|
||||
<Textarea
|
||||
id="refundReason"
|
||||
placeholder="Please explain why you're requesting a refund..."
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRefundRequest}>Request Refund</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/agptui/Button";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
import { RefundModal } from "./RefundModal";
|
||||
import { CreditTransaction } from "@/lib/autogpt-server-api";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -24,7 +27,15 @@ export default function CreditsPage() {
|
||||
transactionHistory,
|
||||
fetchTransactionHistory,
|
||||
formatCredits,
|
||||
} = useCredits();
|
||||
refundTopUp,
|
||||
refundRequests,
|
||||
fetchRefundRequests,
|
||||
} = useCredits({
|
||||
fetchInitialAutoTopUpConfig: true,
|
||||
fetchInitialRefundRequests: true,
|
||||
fetchInitialTransactionHistory: true,
|
||||
fetchTopUpLibrary: true,
|
||||
});
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const topupStatus = searchParams.get("topup") as "success" | "cancel" | null;
|
||||
@@ -32,7 +43,7 @@ export default function CreditsPage() {
|
||||
|
||||
const toastOnFail = useCallback(
|
||||
(action: string, fn: () => Promise<any>) => {
|
||||
fn().catch((e) => {
|
||||
return fn().catch((e) => {
|
||||
toast({
|
||||
title: `Unable to ${action}`,
|
||||
description: e.message,
|
||||
@@ -44,6 +55,33 @@ export default function CreditsPage() {
|
||||
[toast],
|
||||
);
|
||||
|
||||
const [isRefundModalOpen, setIsRefundModalOpen] = useState(false);
|
||||
const [topUpTransactions, setTopUpTransactions] = useState<
|
||||
CreditTransaction[]
|
||||
>([]);
|
||||
const openRefundModal = () => {
|
||||
api.getTransactionHistory(null, 20, "TOP_UP").then((history) => {
|
||||
setTopUpTransactions(history.transactions);
|
||||
setIsRefundModalOpen(true);
|
||||
});
|
||||
};
|
||||
const refundCredits = async (transaction_key: string, reason: string) =>
|
||||
toastOnFail("refund transaction", async () => {
|
||||
const amount = await refundTopUp(transaction_key, reason);
|
||||
if (amount > 0) {
|
||||
toast({
|
||||
title: "Refund approved! 🎉",
|
||||
description: `Your refund has been automatically processed. Based on your remaining balance, the amount of ${formatCredits(amount)} will be credited to your account.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Refund Request Received",
|
||||
description:
|
||||
"We have received your refund request. A member of our team will review it and reach out via email shortly.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (api && topupStatus === "success") {
|
||||
toastOnFail("fulfill checkout", () => api.fulfillCheckout());
|
||||
@@ -86,8 +124,8 @@ export default function CreditsPage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
{/* Top-up Form */}
|
||||
<div>
|
||||
<h2 className="text-lg">Top-up Credits</h2>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Top-up Credits</h3>
|
||||
|
||||
<p className="mb-6 text-neutral-600 dark:text-neutral-400">
|
||||
{topupStatus === "success" && (
|
||||
@@ -131,7 +169,7 @@ export default function CreditsPage() {
|
||||
</form>
|
||||
|
||||
{/* Auto Top-up Form */}
|
||||
<form onSubmit={submitAutoTopUpConfig} className="mt-6 space-y-4">
|
||||
<form onSubmit={submitAutoTopUpConfig} className="my-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">Automatic Refill Settings</h3>
|
||||
|
||||
<div>
|
||||
@@ -212,16 +250,14 @@ export default function CreditsPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="my-6 space-y-4">
|
||||
{/* Payment Portal */}
|
||||
<h2 className="text-lg">Manage Your Payment Methods</h2>
|
||||
<br />
|
||||
|
||||
<h3 className="text-lg font-medium">Manage Your Payment Methods</h3>
|
||||
<p className="text-neutral-600">
|
||||
You can manage your cards and see your payment history in the
|
||||
billing portal.
|
||||
</p>
|
||||
<br />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
@@ -232,13 +268,11 @@ export default function CreditsPage() {
|
||||
</Button>
|
||||
|
||||
{/* Transaction History */}
|
||||
<h2 className="mt-6 text-lg">Transaction History</h2>
|
||||
<br />
|
||||
<h3 className="text-lg font-medium">Transaction History</h3>
|
||||
<p className="text-neutral-600">
|
||||
Running balance might not be ordered accurately when concurrent
|
||||
executions are happening.
|
||||
</p>
|
||||
<br />
|
||||
{transactionHistory.transactions.length === 0 && (
|
||||
<p className="text-neutral-600">No transactions found.</p>
|
||||
)}
|
||||
@@ -259,7 +293,16 @@ export default function CreditsPage() {
|
||||
{transactionHistory.transactions.map((transaction, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
{new Date(transaction.transaction_time).toLocaleString()}
|
||||
{new Date(transaction.transaction_time).toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
},
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{transaction.description}</TableCell>
|
||||
{/* Make it green if it's positive, red if it's negative */}
|
||||
@@ -284,6 +327,58 @@ export default function CreditsPage() {
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{refundRequests.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-lg font-medium">Your Refund Requests</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Last Updated</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{refundRequests.map((request, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
{new Date(request.updated_at).toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
},
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatCredits(request.amount)}</TableCell>
|
||||
<TableCell>{request.status}</TableCell>
|
||||
<TableCell>{request.result}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => openRefundModal()}
|
||||
className="w-full"
|
||||
>
|
||||
Request Refund
|
||||
</Button>
|
||||
<RefundModal
|
||||
isOpen={isRefundModalOpen}
|
||||
onClose={() => setIsRefundModalOpen(false)}
|
||||
transactions={topUpTransactions}
|
||||
formatCredits={formatCredits}
|
||||
refundCredits={refundCredits}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,9 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
|
||||
const CreditsCard = () => {
|
||||
const { credits, formatCredits, fetchCredits } = useCredits();
|
||||
const { credits, formatCredits, fetchCredits } = useCredits({
|
||||
fetchInitialCredits: true,
|
||||
});
|
||||
const api = useBackendAPI();
|
||||
|
||||
const onRefresh = async () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { InputItem } from "@/components/RunnerUIWrapper";
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import useCredits from "./useCredits";
|
||||
import { default as NextLink } from "next/link";
|
||||
|
||||
const ajv = new Ajv({ strict: false, allErrors: true });
|
||||
@@ -76,7 +75,6 @@ export default function useAgentGraph(
|
||||
useState(false);
|
||||
const [nodes, setNodes] = useState<CustomNode[]>([]);
|
||||
const [edges, setEdges] = useState<CustomEdge[]>([]);
|
||||
const { credits, fetchCredits } = useCredits();
|
||||
|
||||
const api = useMemo(
|
||||
() => new BackendAPI(process.env.NEXT_PUBLIC_AGPT_SERVER_URL!),
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
|
||||
import { TransactionHistory } from "@/lib/autogpt-server-api/types";
|
||||
import {
|
||||
RefundRequest,
|
||||
TransactionHistory,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { loadStripe, Stripe } from "@stripe/stripe-js";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function useCredits(): {
|
||||
export default function useCredits({
|
||||
fetchInitialCredits = false,
|
||||
fetchInitialAutoTopUpConfig = false,
|
||||
fetchInitialTransactionHistory = false,
|
||||
fetchInitialRefundRequests = false,
|
||||
fetchTopUpLibrary = false,
|
||||
}: {
|
||||
fetchInitialCredits?: boolean;
|
||||
fetchInitialAutoTopUpConfig?: boolean;
|
||||
fetchInitialTransactionHistory?: boolean;
|
||||
fetchInitialRefundRequests?: boolean;
|
||||
fetchTopUpLibrary?: boolean;
|
||||
} = {}): {
|
||||
credits: number | null;
|
||||
fetchCredits: () => void;
|
||||
requestTopUp: (credit_amount: number) => Promise<void>;
|
||||
refundTopUp: (transaction_key: string, reason: string) => Promise<number>;
|
||||
autoTopUpConfig: { amount: number; threshold: number } | null;
|
||||
fetchAutoTopUpConfig: () => void;
|
||||
updateAutoTopUpConfig: (amount: number, threshold: number) => Promise<void>;
|
||||
transactionHistory: TransactionHistory;
|
||||
fetchTransactionHistory: () => void;
|
||||
refundRequests: RefundRequest[];
|
||||
fetchRefundRequests: () => void;
|
||||
formatCredits: (credit: number | null) => string;
|
||||
} {
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
@@ -31,10 +49,12 @@ export default function useCredits(): {
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchInitialCredits) return;
|
||||
fetchCredits();
|
||||
}, [fetchCredits]);
|
||||
}, [fetchCredits, fetchInitialCredits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchTopUpLibrary) return;
|
||||
const fetchStripe = async () => {
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY?.trim()) {
|
||||
console.debug("Stripe publishable key is not set.");
|
||||
@@ -46,7 +66,7 @@ export default function useCredits(): {
|
||||
setStripe(stripe);
|
||||
};
|
||||
fetchStripe();
|
||||
}, []);
|
||||
}, [fetchTopUpLibrary]);
|
||||
|
||||
const fetchAutoTopUpConfig = useCallback(async () => {
|
||||
const response = await api.getAutoTopUpConfig();
|
||||
@@ -54,8 +74,9 @@ export default function useCredits(): {
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchInitialAutoTopUpConfig) return;
|
||||
fetchAutoTopUpConfig();
|
||||
}, [fetchAutoTopUpConfig]);
|
||||
}, [fetchAutoTopUpConfig, fetchInitialAutoTopUpConfig]);
|
||||
|
||||
const updateAutoTopUpConfig = useCallback(
|
||||
async (amount: number, threshold: number) => {
|
||||
@@ -81,6 +102,16 @@ export default function useCredits(): {
|
||||
[api, router, stripe],
|
||||
);
|
||||
|
||||
const refundTopUp = useCallback(
|
||||
async (transaction_key: string, reason: string) => {
|
||||
const refunded_amount = await api.refundTopUp(transaction_key, reason);
|
||||
await fetchCredits();
|
||||
setTransactionHistory(await api.getTransactionHistory());
|
||||
return refunded_amount;
|
||||
},
|
||||
[api, fetchCredits],
|
||||
);
|
||||
|
||||
const [transactionHistory, setTransactionHistory] =
|
||||
useState<TransactionHistory>({
|
||||
transactions: [],
|
||||
@@ -102,11 +133,24 @@ export default function useCredits(): {
|
||||
}, [api, transactionHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchInitialTransactionHistory) return;
|
||||
fetchTransactionHistory();
|
||||
// Note: We only need to fetch transaction history once.
|
||||
// Hence, we should avoid `fetchTransactionHistory` to the dependency array.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [fetchInitialTransactionHistory]);
|
||||
|
||||
const [refundRequests, setRefundRequests] = useState<RefundRequest[]>([]);
|
||||
|
||||
const fetchRefundRequests = useCallback(async () => {
|
||||
const response = await api.getRefundRequests();
|
||||
setRefundRequests(response);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchInitialRefundRequests) return;
|
||||
fetchRefundRequests();
|
||||
}, [fetchRefundRequests, fetchInitialRefundRequests]);
|
||||
|
||||
const formatCredits = useCallback((credit: number | null) => {
|
||||
if (credit === null) {
|
||||
@@ -123,11 +167,14 @@ export default function useCredits(): {
|
||||
credits,
|
||||
fetchCredits,
|
||||
requestTopUp,
|
||||
refundTopUp,
|
||||
autoTopUpConfig,
|
||||
fetchAutoTopUpConfig,
|
||||
updateAutoTopUpConfig,
|
||||
transactionHistory,
|
||||
fetchTransactionHistory,
|
||||
refundRequests,
|
||||
fetchRefundRequests,
|
||||
formatCredits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@ import {
|
||||
TransactionHistory,
|
||||
User,
|
||||
UserPasswordCredentials,
|
||||
RefundRequest,
|
||||
} from "./types";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import getServerSupabase from "../supabase/getServerSupabase";
|
||||
import { filter } from "../../../test-runner-jest.config";
|
||||
|
||||
const isClient = typeof window !== "undefined";
|
||||
|
||||
@@ -101,26 +103,31 @@ export default class BackendAPI {
|
||||
}
|
||||
|
||||
getTransactionHistory(
|
||||
lastTransction: Date | null,
|
||||
countLimit: number,
|
||||
lastTransction: Date | null = null,
|
||||
countLimit: number | null = null,
|
||||
transactionType: string | null = null,
|
||||
): Promise<TransactionHistory> {
|
||||
return this._get(
|
||||
`/credits/transactions`,
|
||||
lastTransction
|
||||
? {
|
||||
transaction_time: lastTransction,
|
||||
transaction_count_limit: countLimit,
|
||||
}
|
||||
: {
|
||||
transaction_count_limit: countLimit,
|
||||
},
|
||||
);
|
||||
const filters: Record<string, any> = {};
|
||||
if (lastTransction) filters.transaction_time = lastTransction;
|
||||
if (countLimit) filters.transaction_count_limit = countLimit;
|
||||
if (transactionType) filters.transaction_type = transactionType;
|
||||
return this._get(`/credits/transactions`, filters);
|
||||
}
|
||||
|
||||
getRefundRequests(): Promise<RefundRequest[]> {
|
||||
return this._get(`/credits/refunds`);
|
||||
}
|
||||
|
||||
requestTopUp(credit_amount: number): Promise<{ checkout_url: string }> {
|
||||
return this._request("POST", "/credits", { credit_amount });
|
||||
}
|
||||
|
||||
refundTopUp(transaction_key: string, reason: string): Promise<number> {
|
||||
return this._request("POST", `/credits/${transaction_key}/refund`, {
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
getUserPaymentPortalLink(): Promise<{ url: string }> {
|
||||
return this._get("/credits/manage");
|
||||
}
|
||||
|
||||
@@ -574,6 +574,7 @@ export interface CreateAPIKeyResponse {
|
||||
}
|
||||
|
||||
export interface CreditTransaction {
|
||||
transaction_key: string;
|
||||
transaction_time: Date;
|
||||
transaction_type: string;
|
||||
amount: number;
|
||||
@@ -589,3 +590,15 @@ export interface TransactionHistory {
|
||||
transactions: CreditTransaction[];
|
||||
next_transaction_time: Date | null;
|
||||
}
|
||||
|
||||
export interface RefundRequest {
|
||||
id: string;
|
||||
user_id: string;
|
||||
transaction_key: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
result: string | null;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user