credits deduction implementation, real time updating w optimistic updates and db validation/refund

This commit is contained in:
starmorph
2025-05-23 11:31:04 -07:00
parent c9eeb3e14d
commit 6bb3e8f17a
11 changed files with 665 additions and 131 deletions
+195
View File
@@ -0,0 +1,195 @@
# Credit Deduction System
This document explains how the credit deduction system is implemented in the fullstack chat application.
## Overview
The credit system deducts 1 credit from the user's account for every LLM request made through the chat interface. This includes:
- Sending new messages
- Regenerating AI responses
- Editing messages (which triggers a new LLM request)
## Implementation
### Core Components
1. **`deductUserCredits` function** (`src/lib/stripe.ts`)
- Handles the actual credit deduction logic
- Updates user's credit balance in Supabase
- Returns success status and new balance
2. **`useCreditDeduction` hook** (`src/hooks/use-credit-deduction.ts`)
- Centralized hook for credit deduction logic
- Handles authentication checks
- Provides user-friendly error messages
- Shows toast notifications
- **NEW**: Integrates with global credits context for real-time UI updates
3. **`CreditsProvider` context** (`src/providers/Credits.tsx`)
- **NEW**: Global state management for credits
- Provides optimistic updates for instant UI feedback
- Handles credit fetching and error states
- Enables real-time credit balance updates across the app
4. **`CreditBalance` component** (`src/components/credits/credit-balance.tsx`)
- **NEW**: Real-time credit balance display with Badge design
- Color-coded indicators (red/orange/green) based on credit levels
- Automatic updates when credits are deducted
- Loading and error states
5. **Credit integration points:**
- `src/components/thread/index.tsx` - Main message submission and regeneration
- `src/components/thread/messages/human.tsx` - Message editing
### How It Works
1. **Pre-request validation**: Before any LLM request is made, the system:
- Checks if the user is authenticated
- **NEW**: Optimistically deducts credits from UI immediately
- Attempts to deduct credits from database
- Only proceeds with the LLM request if credits are successfully deducted
- **NEW**: Reverts optimistic update if deduction fails
2. **Real-time UI Updates**:
- Credits are deducted from the UI instantly for immediate feedback
- Database is updated in the background
- UI is refreshed with actual balance from server
- Failed requests automatically refund credits
3. **Error handling**: If credit deduction fails:
- **NEW**: Automatically reverts optimistic UI updates
- Shows appropriate error messages (insufficient credits, authentication required)
- Prevents the LLM request from being made
- Suggests purchasing more credits when needed
4. **Success feedback**: When credits are deducted:
- **NEW**: UI updates immediately (optimistic)
- Shows a success toast with remaining balance
- Proceeds with the LLM request
- **NEW**: Refreshes with actual balance from server
### User Experience
- **Immediate feedback**: Users see credit deduction instantly in the UI
- **Fail-fast approach**: Prevents unnecessary LLM calls when credits are insufficient
- **Clear messaging**: Descriptive error messages explain what went wrong
- **Real-time balance visibility**: Credit balance updates across all components instantly
- **Automatic error recovery**: Failed requests automatically refund credits
## Credit Balance Display
The `CreditBalance` component (`src/components/credits/credit-balance.tsx`) shows users their current credit balance with:
- **Badge design** with professional styling
- **Real-time updates** when credits are deducted/added
- **Color-coded indicators**:
- **Red**: 0 credits (urgent)
- **Orange**: 1-5 credits (low)
- **Green**: 6+ credits (good)
- **Loading states** with pulse animation
- **Error handling** with fallback displays
- **Number formatting** (1,000 instead of 1000)
## Global State Management
### CreditsProvider Context
The `CreditsProvider` wraps the entire application and provides:
```typescript
interface CreditsContextProps {
credits: number | null; // Current credit balance
loading: boolean; // Loading state
error: string | null; // Error state
refreshCredits: () => Promise<void>; // Refresh from server
updateCredits: (newCredits: number) => void; // Set exact amount
deductCredits: (amount: number) => void; // Optimistic deduction
addCredits: (amount: number) => void; // Optimistic addition
}
```
### Integration Pattern
Components can access and update credits using the context:
```typescript
const { credits, deductCredits, addCredits } = useCreditsContext();
// Optimistic deduction
deductCredits(1);
// Later: refresh with actual balance
refreshCredits();
```
## Database Schema
The credit system relies on the `users` table having these columns:
- `credits_available` (integer): Current credit balance
- `subscription_status` (text): User's subscription status
## Error Scenarios
1. **Insufficient Credits**: User has 0 credits remaining
2. **Authentication Required**: User is not signed in
3. **Database Errors**: Network issues or database connectivity problems
4. **Server Overload**: LangGraph server temporarily unavailable (credits auto-refunded)
## Usage Examples
### Basic credit deduction:
```typescript
const { deductCredits } = useCreditDeduction();
const result = await deductCredits({ reason: "send message" });
if (!result.success) {
return; // Error already handled by hook
}
// Proceed with LLM request
```
### Custom credit amount:
```typescript
const result = await deductCredits({
reason: "premium feature",
creditsToDeduct: 5,
showSuccessToast: false,
});
```
### Using global credit state:
```typescript
const { credits, loading, error } = useCreditsContext();
if (loading) return <Spinner />;
if (error) return <ErrorMessage />;
return <div>Credits: {credits}</div>;
```
## Integration Points
- **Message submission**: `handleSubmit` in Thread component
- **Message regeneration**: `handleRegenerate` in Thread component
- **Message editing**: `handleSubmitEdit` in HumanMessage component
- **Global credit display**: `CreditBalance` component in navbar
- **State management**: `CreditsProvider` in root layout
## Future Enhancements
1. **Variable credit costs**: Different LLM models could cost different amounts
2. **Credit packages**: Bulk credit purchases with discounts
3. **Usage analytics**: Track credit usage patterns
4. **Credit expiration**: Time-based credit expiration
5. **Subscription integration**: Automatic credit replenishment for subscribers
6. **Credit notifications**: Push notifications for low credit warnings
7. **Credit history**: Transaction log for credit usage tracking
+4 -1
View File
@@ -15,10 +15,13 @@ First, clone the repository, or run the [`npx` command](https://www.npmjs.com/pa
```bash
npx create-agent-chat-app
```
Stripe Local webhook testing (stripe-cli)
```
stripe listen --events customer.subscription.created,customer.subscription.updated,customer.subscription.deleted --forward-to localhost:3000/api/webhooks/stripe
```
```
or
```bash
+4 -1
View File
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
import React from "react";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { AuthProvider } from "@/providers/Auth";
import { CreditsProvider } from "@/providers/Credits";
import AuthLayout from "./auth-layout";
const inter = Inter({
@@ -27,7 +28,9 @@ export default function RootLayout({
<body className={inter.className}>
<NuqsAdapter>
<AuthProvider>
<AuthLayout>{children}</AuthLayout>
<CreditsProvider>
<AuthLayout>{children}</AuthLayout>
</CreditsProvider>
</AuthProvider>
</NuqsAdapter>
</body>
-101
View File
@@ -1,101 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Coins, AlertCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useUser } from "@/lib/auth/supabase-client";
import { cn } from "@/lib/utils";
export function CreditsDisplay() {
const [credits, setCredits] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const { user } = useUser();
useEffect(() => {
const fetchCredits = async () => {
if (!user) {
setLoading(false);
return;
}
try {
console.log("CreditsDisplay: Fetching credits for user:", user.id);
const response = await fetch(`/api/user/credits?userId=${user.id}`);
if (!response.ok) {
console.error(
"CreditsDisplay: Credits API failed with status:",
response.status,
);
const errorText = await response.text();
console.error("CreditsDisplay: Error response:", errorText);
setCredits(0); // Fallback to 0 credits
return;
}
const data = await response.json();
console.log("CreditsDisplay: Credits API response:", data);
if (data.credits !== undefined) {
setCredits(data.credits);
} else {
setCredits(0); // Fallback if no credits field
}
} catch (error) {
console.error("CreditsDisplay: Failed to fetch credits:", error);
setCredits(0); // Fallback to 0 credits on any error
} finally {
setLoading(false);
}
};
fetchCredits();
}, [user]);
// Don't show anything if user is not logged in
if (!user) {
return null;
}
// Show loading state
if (loading) {
return (
<div className="bg-muted flex animate-pulse items-center space-x-2 rounded-full px-3 py-1.5">
<Coins className="h-4 w-4" />
<span className="text-sm font-medium">Loading...</span>
</div>
);
}
// Determine color based on credit amount
const getVariant = (credits: number) => {
if (credits === 0) return "secondary";
if (credits < 1000) return "secondary";
return "default";
};
const getIcon = (credits: number) => {
if (credits === 0) return <AlertCircle className="h-4 w-4" />;
return <Coins className="h-4 w-4" />;
};
return (
<Badge
variant={getVariant(credits || 0)}
className={cn(
"flex items-center space-x-1 px-3 py-1.5 text-sm font-medium",
credits === 0 && "animate-pulse",
)}
>
{getIcon(credits || 0)}
<span>
{credits === null
? "Error"
: credits >= 1000000
? "Unlimited"
: credits.toLocaleString()}{" "}
credits
</span>
</Badge>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useAuthContext } from "@/providers/Auth";
import { useCreditsContext } from "@/providers/Credits";
import { Coins, AlertCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface CreditBalanceProps {
className?: string;
showIcon?: boolean;
}
export function CreditBalance({
className,
showIcon = true,
}: CreditBalanceProps) {
const { isAuthenticated } = useAuthContext();
const { credits, loading, error } = useCreditsContext();
// Don't show anything if user is not logged in
if (!isAuthenticated) {
return null;
}
// Show loading state
if (loading) {
return (
<Badge
variant="secondary"
className={cn(
"flex animate-pulse items-center space-x-2 px-3 py-1.5",
className,
)}
>
{showIcon && <Coins className="h-4 w-4" />}
<span className="text-sm font-medium">Loading...</span>
</Badge>
);
}
// Handle error state
if (error || credits === null) {
return (
<Badge
variant="destructive"
className={cn("flex items-center space-x-2 px-3 py-1.5", className)}
>
{showIcon && <AlertCircle className="h-4 w-4" />}
<span className="text-sm font-medium">Error</span>
</Badge>
);
}
// Determine badge variant based on credit amount
const getBadgeVariant = (credits: number) => {
if (credits === 0) return "destructive";
if (credits <= 5) return "secondary";
return "default";
};
// Get appropriate icon based on credit amount
const getIcon = (credits: number) => {
if (credits === 0) return <AlertCircle className="h-4 w-4" />;
return <Coins className="h-4 w-4 text-yellow-500" />;
};
// Format credit display
const formatCredits = (credits: number) => {
if (credits >= 1000000) return "Unlimited";
return credits.toLocaleString();
};
return (
<Badge
variant={getBadgeVariant(credits)}
className={cn(
"flex items-center space-x-2 px-3 py-1.5 text-sm font-medium",
credits === 0 && "animate-pulse",
className,
)}
>
{showIcon && getIcon(credits)}
<span>
{formatCredits(credits)} {credits === 1 ? "credit" : "credits"}
</span>
</Badge>
);
}
+3 -3
View File
@@ -16,7 +16,7 @@ import {
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { UserInfoSignOut } from "@/features/user-auth-status";
import { GraphDropdown } from "@/features/graph-dropdown";
import { CreditsDisplay } from "@/components/credits-display";
import { CreditBalance } from "@/components/credits/credit-balance";
export function Navbar() {
return (
@@ -52,7 +52,7 @@ export function Navbar() {
</div>
<div className="ml-auto flex items-center space-x-4">
<div className="hidden items-center space-x-3 md:flex">
<CreditsDisplay />
<CreditBalance />
<UserInfoSignOut />
<GraphDropdown />
</div>
@@ -70,7 +70,7 @@ export function Navbar() {
<SheetContent side="right">
<nav className="flex flex-col gap-4">
<div className="mb-4">
<CreditsDisplay />
<CreditBalance />
</div>
<Link
href="/"
+106 -23
View File
@@ -46,6 +46,7 @@ import {
ArtifactTitle,
useArtifactContext,
} from "./artifact";
import { useCreditDeduction } from "@/hooks/use-credit-deduction";
function StickyToBottomContent(props: {
content: ReactNode;
@@ -145,6 +146,8 @@ export function Thread() {
const lastError = useRef<string | undefined>(undefined);
const { deductCredits } = useCreditDeduction();
const setThreadId = (id: string | null) => {
_setThreadId(id);
@@ -195,10 +198,18 @@ export function Thread() {
prevMessageLength.current = messages.length;
}, [messages]);
const handleSubmit = (e: FormEvent) => {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading)
return;
// Deduct 1 credit before making the LLM request
const creditResult = await deductCredits({ reason: "send message" });
if (!creditResult.success) {
return;
}
setFirstTokenReceived(false);
const newHumanMessage: Message = {
@@ -215,36 +226,108 @@ export function Thread() {
const context =
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
stream.submit(
{ messages: [...toolMessages, newHumanMessage], context },
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
context,
messages: [
...(prev.messages ?? []),
...toolMessages,
newHumanMessage,
],
}),
},
);
try {
stream.submit(
{ messages: [...toolMessages, newHumanMessage], context },
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
context,
messages: [
...(prev.messages ?? []),
...toolMessages,
newHumanMessage,
],
}),
},
);
setInput("");
setContentBlocks([]);
setInput("");
setContentBlocks([]);
} catch (error: any) {
// Handle server overload and other errors
if (
error?.error?.type === "overloaded_error" ||
error?.message?.includes("Overloaded")
) {
// Refund credits for overloaded server
if (creditResult.refundCredits) {
await creditResult.refundCredits();
}
toast.error("Server temporarily overloaded", {
description:
"The AI server is busy. Please try again in a moment. Your credit has been refunded.",
duration: 6000,
});
} else {
// For other errors, still refund credits since the request failed
if (creditResult.refundCredits) {
await creditResult.refundCredits();
}
toast.error("Request failed", {
description:
"There was an error processing your message. Your credit has been refunded.",
duration: 5000,
});
}
console.error("Submit error:", error);
}
};
const handleRegenerate = (
const handleRegenerate = async (
parentCheckpoint: Checkpoint | null | undefined,
) => {
// Deduct 1 credit before making the LLM request
const creditResult = await deductCredits({ reason: "regenerate message" });
if (!creditResult.success) {
return;
}
// Do this so the loading state is correct
prevMessageLength.current = prevMessageLength.current - 1;
setFirstTokenReceived(false);
stream.submit(undefined, {
checkpoint: parentCheckpoint,
streamMode: ["values"],
});
try {
stream.submit(undefined, {
checkpoint: parentCheckpoint,
streamMode: ["values"],
});
} catch (error: any) {
// Handle server overload and other errors
if (
error?.error?.type === "overloaded_error" ||
error?.message?.includes("Overloaded")
) {
// Refund credits for overloaded server
if (creditResult.refundCredits) {
await creditResult.refundCredits();
}
toast.error("Server temporarily overloaded", {
description:
"The AI server is busy. Please try again in a moment. Your credit has been refunded.",
duration: 6000,
});
} else {
// For other errors, still refund credits since the request failed
if (creditResult.refundCredits) {
await creditResult.refundCredits();
}
toast.error("Regeneration failed", {
description:
"There was an error regenerating the message. Your credit has been refunded.",
duration: 5000,
});
}
console.error("Regenerate error:", error);
}
};
const chatStarted = !!threadId || !!messages.length;
+10 -1
View File
@@ -7,6 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared";
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
import { useCreditDeduction } from "@/hooks/use-credit-deduction";
function EditableContent({
value,
@@ -42,6 +43,7 @@ export function HumanMessage({
isLoading: boolean;
}) {
const thread = useStreamContext();
const { deductCredits } = useCreditDeduction();
const meta = thread.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
@@ -49,7 +51,14 @@ export function HumanMessage({
const [value, setValue] = useState("");
const contentString = getContentString(message.content);
const handleSubmitEdit = () => {
const handleSubmitEdit = async () => {
// Deduct 1 credit before making the LLM request for the edited message
const creditResult = await deductCredits({ reason: "edit message" });
if (!creditResult.success) {
return;
}
setIsEditing(false);
const newMessage: Message = { type: "human", content: value };
+127
View File
@@ -0,0 +1,127 @@
import { useAuthContext } from "@/providers/Auth";
import { useCreditsContext } from "@/providers/Credits";
import { deductUserCredits, addUserCredits } from "@/lib/stripe";
import { toast } from "sonner";
interface CreditDeductionOptions {
/** The reason for the credit deduction (used in toast messages) */
reason?: string;
/** Number of credits to deduct (default: 1) */
creditsToDeduct?: number;
/** Whether to show success toast (default: true) */
showSuccessToast?: boolean;
}
interface CreditDeductionResult {
success: boolean;
newBalance?: number;
error?: string;
refundCredits?: () => Promise<void>;
}
export function useCreditDeduction() {
const { user, isAuthenticated } = useAuthContext();
const {
deductCredits: optimisticDeduct,
addCredits: optimisticAdd,
refreshCredits,
} = useCreditsContext();
const deductCredits = async (
options: CreditDeductionOptions = {},
): Promise<CreditDeductionResult> => {
const {
reason = "message",
creditsToDeduct = 1,
showSuccessToast = true,
} = options;
// Check if user is authenticated
if (!isAuthenticated || !user?.id) {
toast.error("Authentication required", {
description: `You must be signed in to ${reason === "message" ? "send messages" : reason}.`,
duration: 5000,
});
return { success: false, error: "Not authenticated" };
}
try {
// Optimistically deduct credits from UI immediately
optimisticDeduct(creditsToDeduct);
// Deduct credits from database
const creditResult = await deductUserCredits(user.id, creditsToDeduct);
if (!creditResult.success) {
// Revert the optimistic update by adding credits back
optimisticAdd(creditsToDeduct);
toast.error("Insufficient credits", {
description: `You don't have enough credits to ${reason === "message" ? "send this message" : reason}.`,
duration: 5000,
});
return { success: false, error: "Insufficient credits" };
}
// Update the credits context with the actual balance from server
refreshCredits();
// Show success toast if enabled
if (showSuccessToast) {
const creditText = creditsToDeduct === 1 ? "credit" : "credits";
toast.success(`${creditsToDeduct} ${creditText} deducted`, {
description: `Used for ${reason}. Remaining balance: ${creditResult.newBalance}`,
duration: 3000,
});
}
// Return result with refund function for failed requests
return {
success: true,
newBalance: creditResult.newBalance,
refundCredits: async () => {
try {
await addUserCredits(user.id, creditsToDeduct);
// Update UI optimistically
optimisticAdd(creditsToDeduct);
// Refresh to get exact balance
refreshCredits();
toast.info("Credits refunded", {
description: `${creditsToDeduct} ${creditsToDeduct === 1 ? "credit" : "credits"} refunded due to server error.`,
duration: 4000,
});
} catch (error) {
console.error("Failed to refund credits:", error);
// Refresh credits to get the correct state if refund fails
refreshCredits();
}
},
};
} catch (error: any) {
// Revert the optimistic update
optimisticAdd(creditsToDeduct);
if (error.message === "Insufficient credits") {
toast.error("Insufficient credits", {
description: `You don't have enough credits to ${reason === "message" ? "send this message" : reason}. Please purchase more credits.`,
duration: 5000,
});
} else {
toast.error("Credit deduction failed", {
description:
"There was an error processing your credits. Please try again.",
duration: 5000,
});
console.error("Credit deduction error:", error);
}
return { success: false, error: error.message };
}
};
return {
deductCredits,
isAuthenticated,
userId: user?.id,
};
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from "react";
import { useAuthContext } from "@/providers/Auth";
import { supabase } from "@/lib/auth/supabase-client";
interface CreditsContextProps {
credits: number | null;
loading: boolean;
error: string | null;
refreshCredits: () => Promise<void>;
updateCredits: (newCredits: number) => void;
deductCredits: (amount: number) => void;
addCredits: (amount: number) => void;
}
const CreditsContext = createContext<CreditsContextProps | undefined>(
undefined,
);
export function CreditsProvider({ children }: { children: ReactNode }) {
const { user, isAuthenticated } = useAuthContext();
const [credits, setCredits] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refreshCredits = async () => {
if (!isAuthenticated || !user?.id) {
setCredits(null);
setLoading(false);
setError(null);
return;
}
try {
setLoading(true);
setError(null);
const { data, error: supabaseError } = await supabase
.from("users")
.select("credits_available")
.eq("id", user.id)
.single();
if (supabaseError) {
console.error("Error fetching credits:", supabaseError);
setError("Failed to fetch credits");
setCredits(0); // Fallback to 0 credits
return;
}
setCredits((data?.credits_available as number) ?? 0);
} catch (err) {
console.error("Error fetching credits:", err);
setError("Failed to fetch credits");
setCredits(0); // Fallback to 0 credits on any error
} finally {
setLoading(false);
}
};
// Update credits optimistically
const updateCredits = (newCredits: number) => {
setCredits(newCredits);
};
// Deduct credits optimistically
const deductCredits = (amount: number) => {
setCredits((prevCredits) => {
if (prevCredits === null) return null;
return Math.max(0, prevCredits - amount);
});
};
// Add credits optimistically
const addCredits = (amount: number) => {
setCredits((prevCredits) => {
if (prevCredits === null) return null;
return prevCredits + amount;
});
};
// Initial fetch when user changes
useEffect(() => {
refreshCredits();
}, [isAuthenticated, user?.id]);
const value = {
credits,
loading,
error,
refreshCredits,
updateCredits,
deductCredits,
addCredits,
};
return (
<CreditsContext.Provider value={value}>{children}</CreditsContext.Provider>
);
}
export function useCreditsContext() {
const context = useContext(CreditsContext);
if (context === undefined) {
throw new Error("useCreditsContext must be used within a CreditsProvider");
}
return context;
}
+14 -1
View File
@@ -50,8 +50,9 @@ const StreamSession = ({
}) => {
const [threadId, setThreadId] = useQueryState("threadId");
const { getThreads, setThreads } = useThreads();
const { session } = useAuthContext();
const { session, isLoading: authLoading } = useAuthContext();
const jwt = session?.accessToken || undefined;
const streamValue = useTypedStream({
apiUrl,
assistantId,
@@ -78,6 +79,18 @@ const StreamSession = ({
},
});
// Don't render children until auth is fully loaded
if (authLoading) {
return (
<div className="flex min-h-screen w-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
return (
<StreamContext.Provider value={streamValue}>
{children}