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:
Zamil Majdy
2025-01-30 15:15:04 +01:00
committed by GitHub
parent a44c9333d3
commit 1f83a8c61a
7 changed files with 274 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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