fix(ui+billing): dark theme for session page + no double-charge on restart

FocusGroupSession.tsx: replace bg-slate-50 with bg-background so the page
matches the rest of the dark UI; swap remaining text-slate-* for muted tokens.

focus_group_ai.py: before deducting 40 cr for an AI mode run, check if this
focus group already has a charge within the last 4 hours. Skip deduction if
found — prevents double-billing when the server restarts mid-session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-25 12:26:21 +01:00
parent 56efcc38f5
commit 0a419eeee1
2 changed files with 49 additions and 31 deletions

View file

@ -35,6 +35,7 @@ from app.models.credit_transaction import CreditTransaction
from app.models.app_settings import get_settings
from app.utils.rate_limiter import rate_limit
from app.utils import active_required, with_user_context
from app.db import get_db
# Create the blueprint
focus_group_ai_bp = Blueprint('focus_group_ai', __name__)
@ -765,26 +766,43 @@ async def start_autonomous_conversation(focus_group_id):
run_user_id = get_jwt_identity()
settings = await get_settings()
run_cost = settings.get("run_cost", 40)
user_data = await User.find_by_id(run_user_id)
balance = (user_data or {}).get("credits_balance", 0)
if balance < run_cost:
return jsonify({
"error": "Insufficient credits",
"message": f"You need {run_cost} credits to run a focus group session. Current balance: {balance}.",
"credits_required": run_cost,
"credits_balance": balance,
}), 402
new_balance = await User.deduct_credits(run_user_id, run_cost)
if new_balance is None:
return jsonify({"error": "Insufficient credits", "message": "Credit balance changed. Please try again."}), 402
await CreditTransaction.record(
user_id=run_user_id,
tx_type="debit",
amount=-run_cost,
balance_after=new_balance,
description=f"Focus group session run",
ref={"focus_group_id": focus_group_id},
)
# Skip charge if this focus group was already charged within the last 4 hours
# (handles server restarts and error-triggered restarts without double-billing)
from datetime import datetime, timezone, timedelta
db = await get_db()
recent_cutoff = datetime.now(timezone.utc) - timedelta(hours=4)
recent_charge = await db.credit_transactions.find_one({
"user_id": run_user_id,
"ref.focus_group_id": focus_group_id,
"description": "Focus group session run",
"ts": {"$gte": recent_cutoff},
})
if recent_charge is None:
user_data = await User.find_by_id(run_user_id)
balance = (user_data or {}).get("credits_balance", 0)
if balance < run_cost:
return jsonify({
"error": "Insufficient credits",
"message": f"You need {run_cost} credits to run a focus group session. Current balance: {balance}.",
"credits_required": run_cost,
"credits_balance": balance,
}), 402
new_balance = await User.deduct_credits(run_user_id, run_cost)
if new_balance is None:
return jsonify({"error": "Insufficient credits", "message": "Credit balance changed. Please try again."}), 402
await CreditTransaction.record(
user_id=run_user_id,
tx_type="debit",
amount=-run_cost,
balance_after=new_balance,
description="Focus group session run",
ref={"focus_group_id": focus_group_id},
)
current_app.logger.info(f"Charged {run_cost} credits for focus group {focus_group_id}")
else:
current_app.logger.info(f"Focus group {focus_group_id} already charged within 4h window — skipping deduction")
# Create autonomous conversation controller
current_app.logger.info("Creating AutonomousConversationController...")

View file

@ -1762,13 +1762,13 @@ const FocusGroupSession = () => {
// Show loading state while fetching
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50 pt-20 pb-16 px-4">
<div className="min-h-screen bg-background pt-20 pb-16 px-4">
<div className="max-w-7xl mx-auto text-center py-12">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<p className="mt-4 text-slate-600">Loading focus group...</p>
<p className="mt-4 text-muted-foreground">Loading focus group...</p>
</div>
</div>
);
@ -1777,11 +1777,11 @@ const FocusGroupSession = () => {
// Show error state if not found after loading
if (!focusGroup) {
return (
<div className="min-h-screen bg-slate-50 pt-20 pb-16 px-4">
<div className="min-h-screen bg-background pt-20 pb-16 px-4">
<div className="max-w-7xl mx-auto text-center py-12">
<h1 className="text-2xl font-bold">Focus group not found</h1>
<p className="mt-2 text-slate-600">We couldn't find the focus group you're looking for.</p>
<p className="mt-2 text-muted-foreground">We couldn't find the focus group you're looking for.</p>
<Button
onClick={() => navigate('/focus-groups')}
className="mt-4"
@ -1794,7 +1794,7 @@ const FocusGroupSession = () => {
}
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen bg-background">
{/* WebSocket Connection Status Bar */}
@ -1887,7 +1887,7 @@ const FocusGroupSession = () => {
<h1 className="font-sf text-2xl font-bold text-foreground">{focusGroup.name}</h1>
<p className="text-muted-foreground">{new Date(focusGroup.date).toLocaleString()}</p>
<div className="flex items-center mt-1">
<Bot className="h-3 w-3 text-slate-500 mr-1" />
<Bot className="h-3 w-3 text-muted-foreground mr-1" />
<Badge variant="secondary" className="text-xs">
{focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
</Badge>
@ -2019,7 +2019,7 @@ const FocusGroupSession = () => {
<TabsContent value="chat" className="m-0 flex-1 flex flex-col h-0">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<p className="text-lg text-slate-600">No messages yet. Start the session to begin the discussion.</p>
<p className="text-lg text-muted-foreground">No messages yet. Start the session to begin the discussion.</p>
<Button
onClick={startSession}
size="lg"
@ -2261,7 +2261,7 @@ const FocusGroupSession = () => {
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-slate-500" />
<Bot className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Current Model:</span>
<Badge variant="secondary">
{focusGroup?.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
@ -2300,7 +2300,7 @@ const FocusGroupSession = () => {
<SelectItem value="high">High - Deep reasoning</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-600 mt-1">
<p className="text-xs text-muted-foreground mt-1">
Controls how much time GPT-5.4 spends thinking before responding
</p>
<p className="text-xs text-amber-600 font-medium mt-1">
@ -2321,7 +2321,7 @@ Controls how thoroughly GPT-5.4 thinks and how detailed responses are
<SelectItem value="high">High - Detailed responses</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-600 mt-1">
<p className="text-xs text-muted-foreground mt-1">
Controls how detailed and lengthy GPT-5.4's responses will be
</p>
<p className="text-xs text-amber-600 font-medium mt-1">
@ -2331,7 +2331,7 @@ Controls how thoroughly GPT-5.4 thinks and how detailed responses are
</>
)}
<div className="text-xs text-slate-600">
<div className="text-xs text-muted-foreground">
<p><strong>GPT-5.4:</strong> Recommended model. Best quality for complex analysis and persona responses.</p>
<p><strong>GPT-5.4 Mini:</strong> Faster and lower cost. Great for most tasks with good quality.</p>
</div>