mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-10 07:38:04 -05:00
feat(platform): Implement User Credit Transaction History (#9291)
<img width="1129" alt="image" src="https://github.com/user-attachments/assets/94aab319-7755-413b-91e1-4ad1ba383b83" /> ### Changes 🏗️ Add a transaction history table on the user credits page. ### 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> --------- Co-authored-by: Krzysztof Czerwinski <kpczerwinski@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import stripe
|
||||
@@ -8,13 +9,14 @@ from prisma.enums import CreditTransactionType
|
||||
from prisma.errors import UniqueViolationError
|
||||
from prisma.models import CreditTransaction, User
|
||||
from prisma.types import CreditTransactionCreateInput, CreditTransactionWhereInput
|
||||
from pydantic import BaseModel
|
||||
|
||||
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
|
||||
from backend.data.model import AutoTopUpConfig, TransactionHistory, UserTransaction
|
||||
from backend.data.user import get_user_by_id
|
||||
from backend.util.settings import Settings
|
||||
|
||||
@@ -34,6 +36,26 @@ class UserCreditBase(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_transaction_history(
|
||||
self,
|
||||
user_id: str,
|
||||
transaction_time: datetime,
|
||||
transaction_count_limit: int,
|
||||
) -> 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.
|
||||
|
||||
Returns:
|
||||
TransactionHistory: The credit transactions for the user.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def spend_credits(
|
||||
self,
|
||||
@@ -208,6 +230,16 @@ class UserCreditBase(ABC):
|
||||
return user_balance + amount
|
||||
|
||||
|
||||
class UsageTransactionMetadata(BaseModel):
|
||||
graph_exec_id: str | None = None
|
||||
graph_id: str | None = None
|
||||
node_id: str | None = None
|
||||
node_exec_id: str | None = None
|
||||
block_id: str | None = None
|
||||
block: str | None = None
|
||||
input: BlockInput | None = None
|
||||
|
||||
|
||||
class UserCredit(UserCreditBase):
|
||||
|
||||
def _block_usage_cost(
|
||||
@@ -281,15 +313,15 @@ class UserCredit(UserCreditBase):
|
||||
amount=-cost,
|
||||
transaction_type=CreditTransactionType.USAGE,
|
||||
metadata=Json(
|
||||
{
|
||||
"graph_exec_id": entry.graph_exec_id,
|
||||
"graph_id": entry.graph_id,
|
||||
"node_id": entry.node_id,
|
||||
"node_exec_id": entry.node_exec_id,
|
||||
"block_id": entry.block_id,
|
||||
"block": block.name,
|
||||
"input": matching_filter,
|
||||
}
|
||||
UsageTransactionMetadata(
|
||||
graph_exec_id=entry.graph_exec_id,
|
||||
graph_id=entry.graph_id,
|
||||
node_id=entry.node_id,
|
||||
node_exec_id=entry.node_exec_id,
|
||||
block_id=entry.block_id,
|
||||
block=block.name,
|
||||
input=matching_filter,
|
||||
).model_dump()
|
||||
),
|
||||
)
|
||||
user_id = entry.user_id
|
||||
@@ -455,6 +487,57 @@ class UserCredit(UserCreditBase):
|
||||
balance, _ = await self._get_credits(user_id)
|
||||
return balance
|
||||
|
||||
async def get_transaction_history(
|
||||
self,
|
||||
user_id: str,
|
||||
transaction_time: datetime,
|
||||
transaction_count_limit: int,
|
||||
) -> TransactionHistory:
|
||||
transactions = await CreditTransaction.prisma().find_many(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"createdAt": {"lt": transaction_time},
|
||||
"isActive": True,
|
||||
},
|
||||
order={"createdAt": "desc"},
|
||||
take=transaction_count_limit,
|
||||
)
|
||||
|
||||
grouped_transactions: dict[str, UserTransaction] = defaultdict(
|
||||
lambda: UserTransaction()
|
||||
)
|
||||
tx_time = None
|
||||
for t in transactions:
|
||||
metadata = UsageTransactionMetadata.model_validate(t.metadata)
|
||||
tx_time = t.createdAt.replace(tzinfo=None)
|
||||
|
||||
if t.type == CreditTransactionType.USAGE and metadata.graph_exec_id:
|
||||
gt = grouped_transactions[metadata.graph_exec_id]
|
||||
gid = metadata.graph_id[:8] if metadata.graph_id else "UNKNOWN"
|
||||
gt.description = f"Graph #{gid} Execution"
|
||||
|
||||
gt.usage_node_count += 1
|
||||
gt.usage_start_time = min(gt.usage_start_time, tx_time)
|
||||
gt.usage_execution_id = metadata.graph_exec_id
|
||||
gt.usage_graph_id = metadata.graph_id
|
||||
else:
|
||||
gt = grouped_transactions[t.transactionKey]
|
||||
gt.description = f"{t.type} Transaction"
|
||||
|
||||
gt.amount += t.amount
|
||||
gt.transaction_type = t.type
|
||||
|
||||
if tx_time > gt.transaction_time:
|
||||
gt.transaction_time = tx_time
|
||||
gt.balance = t.runningBalance or 0
|
||||
|
||||
return TransactionHistory(
|
||||
transactions=list(grouped_transactions.values()),
|
||||
next_transaction_time=(
|
||||
tx_time if len(transactions) == transaction_count_limit else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BetaUserCredit(UserCredit):
|
||||
"""
|
||||
@@ -487,6 +570,9 @@ class DisabledUserCredit(UserCreditBase):
|
||||
async def get_credits(self, *args, **kwargs) -> int:
|
||||
return 0
|
||||
|
||||
async def get_transaction_history(self, *args, **kwargs) -> TransactionHistory:
|
||||
return TransactionHistory(transactions=[], next_transaction_time=None)
|
||||
|
||||
async def spend_credits(self, *args, **kwargs) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
@@ -17,6 +18,7 @@ from typing import (
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from prisma.enums import CreditTransactionType
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
@@ -370,3 +372,20 @@ class AutoTopUpConfig(BaseModel):
|
||||
"""Amount of credits to top up."""
|
||||
threshold: int
|
||||
"""Threshold to trigger auto top up."""
|
||||
|
||||
|
||||
class UserTransaction(BaseModel):
|
||||
transaction_time: datetime = datetime.min
|
||||
transaction_type: CreditTransactionType = CreditTransactionType.USAGE
|
||||
amount: int = 0
|
||||
balance: int = 0
|
||||
description: str | None = None
|
||||
usage_graph_id: str | None = None
|
||||
usage_execution_id: str | None = None
|
||||
usage_node_count: int = 0
|
||||
usage_start_time: datetime = datetime.max
|
||||
|
||||
|
||||
class TransactionHistory(BaseModel):
|
||||
transactions: list[UserTransaction]
|
||||
next_transaction_time: datetime | None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Sequence
|
||||
|
||||
import pydantic
|
||||
@@ -31,6 +32,7 @@ from backend.data.api_key import (
|
||||
from backend.data.block import BlockInput, CompletedBlockOutput
|
||||
from backend.data.credit import (
|
||||
AutoTopUpConfig,
|
||||
TransactionHistory,
|
||||
get_auto_top_up,
|
||||
get_block_costs,
|
||||
get_stripe_customer_id,
|
||||
@@ -246,6 +248,22 @@ async def manage_payment_method(
|
||||
return {"url": session.url}
|
||||
|
||||
|
||||
@v1_router.get(path="/credits/transactions", dependencies=[Depends(auth_middleware)])
|
||||
async def get_credit_history(
|
||||
user_id: Annotated[str, Depends(get_user_id)],
|
||||
transaction_time: datetime | None = None,
|
||||
transaction_count_limit: int = 100,
|
||||
) -> TransactionHistory:
|
||||
if transaction_count_limit < 1 or transaction_count_limit > 1000:
|
||||
raise ValueError("Transaction count limit must be between 1 and 1000")
|
||||
|
||||
return await _user_credit_model.get_transaction_history(
|
||||
user_id=user_id,
|
||||
transaction_time=transaction_time or datetime.max,
|
||||
transaction_count_limit=transaction_count_limit,
|
||||
)
|
||||
|
||||
|
||||
########################################################
|
||||
##################### Graphs ###########################
|
||||
########################################################
|
||||
|
||||
@@ -6,9 +6,24 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export default function CreditsPage() {
|
||||
const api = useBackendAPI();
|
||||
const { requestTopUp, autoTopUpConfig, updateAutoTopUpConfig } = useCredits();
|
||||
const {
|
||||
requestTopUp,
|
||||
autoTopUpConfig,
|
||||
updateAutoTopUpConfig,
|
||||
transactionHistory,
|
||||
fetchTransactionHistory,
|
||||
} = useCredits();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const topupStatus = searchParams.get("topup") as "success" | "cancel" | null;
|
||||
@@ -179,6 +194,57 @@ export default function CreditsPage() {
|
||||
>
|
||||
Open Portal
|
||||
</Button>
|
||||
|
||||
{/* Transaction History */}
|
||||
<h2 className="mt-6 text-lg">Transaction History</h2>
|
||||
<br />
|
||||
<p className="text-neutral-600">
|
||||
Running balance might not be ordered accurately when concurrent
|
||||
executions are happening.
|
||||
</p>
|
||||
<br />
|
||||
<Table
|
||||
className={
|
||||
transactionHistory.transactions.length === 0 ? "hidden" : ""
|
||||
}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Balance</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactionHistory.transactions.map((transaction, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
{new Date(transaction.transaction_time).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>{transaction.description}</TableCell>
|
||||
{/* Make it green if it's positive, red if it's negative */}
|
||||
<TableCell
|
||||
className={
|
||||
transaction.amount > 0 ? "text-green-500" : "text-red-500"
|
||||
}
|
||||
>
|
||||
<b>{transaction.amount}</b>
|
||||
</TableCell>
|
||||
<TableCell>{transaction.balance}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{transactionHistory.next_transaction_time && (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
onClick={() => fetchTransactionHistory()}
|
||||
>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
|
||||
import { TransactionHistory } from "@/lib/autogpt-server-api/types";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -14,6 +15,8 @@ export default function useCredits(): {
|
||||
autoTopUpConfig: { amount: number; threshold: number } | null;
|
||||
fetchAutoTopUpConfig: () => void;
|
||||
updateAutoTopUpConfig: (amount: number, threshold: number) => Promise<void>;
|
||||
transactionHistory: TransactionHistory;
|
||||
fetchTransactionHistory: () => void;
|
||||
} {
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const [autoTopUpConfig, setAutoTopUpConfig] = useState<{
|
||||
@@ -64,6 +67,30 @@ export default function useCredits(): {
|
||||
[api, router],
|
||||
);
|
||||
|
||||
const [transactionHistory, setTransactionHistory] =
|
||||
useState<TransactionHistory>({
|
||||
transactions: [],
|
||||
next_transaction_time: null,
|
||||
});
|
||||
|
||||
const fetchTransactionHistory = useCallback(async () => {
|
||||
const response = await api.getTransactionHistory(
|
||||
transactionHistory.next_transaction_time,
|
||||
20,
|
||||
);
|
||||
setTransactionHistory({
|
||||
transactions: [
|
||||
...transactionHistory.transactions,
|
||||
...response.transactions,
|
||||
],
|
||||
next_transaction_time: response.next_transaction_time,
|
||||
});
|
||||
}, [api, transactionHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactionHistory();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
credits,
|
||||
fetchCredits,
|
||||
@@ -71,5 +98,7 @@ export default function useCredits(): {
|
||||
autoTopUpConfig,
|
||||
fetchAutoTopUpConfig,
|
||||
updateAutoTopUpConfig,
|
||||
transactionHistory,
|
||||
fetchTransactionHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
AnalyticsDetails,
|
||||
AnalyticsMetrics,
|
||||
APIKey,
|
||||
APIKeyCredentials,
|
||||
APIKeyPermission,
|
||||
Block,
|
||||
CreatorsResponse,
|
||||
CreatorDetails,
|
||||
CreateAPIKeyResponse,
|
||||
Credentials,
|
||||
CredentialsDeleteNeedConfirmationResponse,
|
||||
CredentialsDeleteResponse,
|
||||
CredentialsMetaResponse,
|
||||
@@ -13,26 +19,21 @@ import {
|
||||
GraphExecuteResponse,
|
||||
GraphMeta,
|
||||
GraphUpdateable,
|
||||
NodeExecutionResult,
|
||||
MyAgentsResponse,
|
||||
NodeExecutionResult,
|
||||
ProfileDetails,
|
||||
User,
|
||||
Schedule,
|
||||
ScheduleCreatable,
|
||||
StoreAgentsResponse,
|
||||
StoreAgentDetails,
|
||||
CreatorsResponse,
|
||||
CreatorDetails,
|
||||
StoreSubmissionsResponse,
|
||||
StoreSubmissionRequest,
|
||||
StoreSubmission,
|
||||
StoreReviewCreate,
|
||||
StoreReview,
|
||||
ScheduleCreatable,
|
||||
Schedule,
|
||||
TransactionHistory,
|
||||
User,
|
||||
UserPasswordCredentials,
|
||||
Credentials,
|
||||
APIKeyPermission,
|
||||
CreateAPIKeyResponse,
|
||||
APIKey,
|
||||
} from "./types";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import getServerSupabase from "../supabase/getServerSupabase";
|
||||
@@ -100,6 +101,23 @@ export default class BackendAPI {
|
||||
return this._request("POST", "/credits/auto-top-up", config);
|
||||
}
|
||||
|
||||
getTransactionHistory(
|
||||
lastTransction: Date | null,
|
||||
countLimit: number,
|
||||
): Promise<TransactionHistory> {
|
||||
return this._get(
|
||||
`/credits/transactions`,
|
||||
lastTransction
|
||||
? {
|
||||
transaction_time: lastTransction,
|
||||
transaction_count_limit: countLimit,
|
||||
}
|
||||
: {
|
||||
transaction_count_limit: countLimit,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
requestTopUp(credit_amount: number): Promise<{ checkout_url: string }> {
|
||||
return this._request("POST", "/credits", { credit_amount });
|
||||
}
|
||||
|
||||
@@ -578,3 +578,20 @@ export interface CreateAPIKeyResponse {
|
||||
api_key: APIKey;
|
||||
plain_text_key: string;
|
||||
}
|
||||
|
||||
export interface CreditTransaction {
|
||||
transaction_time: Date;
|
||||
transaction_type: string;
|
||||
amount: number;
|
||||
balance: number;
|
||||
description: string;
|
||||
usage_graph_id: string;
|
||||
usage_execution_id: string;
|
||||
usage_node_count: number;
|
||||
usage_starting_time: Date;
|
||||
}
|
||||
|
||||
export interface TransactionHistory {
|
||||
transactions: CreditTransaction[];
|
||||
next_transaction_time: Date | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user