diff --git a/CREDIT_SYSTEM.md b/CREDIT_SYSTEM.md new file mode 100644 index 0000000..0de459a --- /dev/null +++ b/CREDIT_SYSTEM.md @@ -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; // 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 ; +if (error) return ; +return
Credits: {credits}
; +``` + +## 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 diff --git a/README.md b/README.md index 691b43f..b4f7e20 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ff1e0f7..c9de496 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - {children} + + {children} + diff --git a/src/components/credits-display.tsx b/src/components/credits-display.tsx deleted file mode 100644 index 23d01d4..0000000 --- a/src/components/credits-display.tsx +++ /dev/null @@ -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(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 ( -
- - Loading... -
- ); - } - - // 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 ; - return ; - }; - - return ( - - {getIcon(credits || 0)} - - {credits === null - ? "Error" - : credits >= 1000000 - ? "Unlimited" - : credits.toLocaleString()}{" "} - credits - - - ); -} diff --git a/src/components/credits/credit-balance.tsx b/src/components/credits/credit-balance.tsx new file mode 100644 index 0000000..7a97249 --- /dev/null +++ b/src/components/credits/credit-balance.tsx @@ -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 ( + + {showIcon && } + Loading... + + ); + } + + // Handle error state + if (error || credits === null) { + return ( + + {showIcon && } + Error + + ); + } + + // 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 ; + return ; + }; + + // Format credit display + const formatCredits = (credits: number) => { + if (credits >= 1000000) return "Unlimited"; + return credits.toLocaleString(); + }; + + return ( + + {showIcon && getIcon(credits)} + + {formatCredits(credits)} {credits === 1 ? "credit" : "credits"} + + + ); +} diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index a154346..3b14b88 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -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() {
- +
@@ -70,7 +70,7 @@ export function Navbar() {