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:
Zamil Majdy
2025-02-18 16:20:46 +07:00
committed by GitHub
parent 0117006373
commit 43460b8553
15 changed files with 796 additions and 93 deletions

View File

@@ -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 users 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}
)

View File

@@ -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

View File

@@ -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 ###########################
########################################################

View File

@@ -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 {}

View File

@@ -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",

View File

@@ -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");

View File

@@ -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';

View File

@@ -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 ///////////////////////////

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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!),

View File

@@ -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,
};
}

View File

@@ -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");
}

View File

@@ -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;
}