fix(usage): hide real costs from users — show credit balance, MTD spend, transaction history

- /api/usage/me now returns credits_balance, credits_spent (MTD debits), and
  last 30 credit transactions; no dollar amounts or token counts exposed
- MyUsage page redesigned: balance + MTD spent cards, transaction history table,
  Buy Credits button
- Admin: POST /api/billing/test-checkout — $1 Stripe Checkout for any credit
  amount to validate the payment flow end-to-end
- stripe_service: cast unit_amount to int (Stripe rejects float)
- i18n updated in EN/RU/UK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-25 13:32:28 +01:00
parent d92a099ade
commit bac7e7bce3
9 changed files with 198 additions and 77 deletions

View file

@ -13,6 +13,7 @@ from app.models.user import User
from app.models.credit_transaction import CreditTransaction
from app.models.app_settings import get_settings
from app.services.stripe_service import create_checkout_session, verify_webhook
from app.utils import admin_required
logger = logging.getLogger(__name__)
billing_bp = Blueprint('billing', __name__)
@ -93,6 +94,40 @@ async def create_checkout():
return jsonify({"message": "Payment service unavailable"}), 503
@billing_bp.route('/test-checkout', methods=['POST'])
@jwt_required()
@admin_required
async def test_checkout():
"""Admin only — create a $1 Stripe Checkout for any number of credits (payment testing)."""
user_id = get_jwt_identity()
data = await request.get_json() or {}
credits = int(data.get("credits", 10))
if credits < 1:
return jsonify({"message": "credits must be >= 1"}), 400
import os
origin = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:5173").split(",")[0].strip()
success_url = f"{origin}/billing?success=1"
cancel_url = f"{origin}/billing?cancelled=1"
try:
url = await create_checkout_session(
pack_id="test",
pack_name=f"Test Pack ({credits} cr)",
price_usd=1,
credits=credits,
user_id=user_id,
success_url=success_url,
cancel_url=cancel_url,
)
return jsonify({"checkout_url": url}), 200
except ValueError as e:
return jsonify({"message": str(e)}), 503
except Exception as e:
logger.error(f"Test checkout error: {e}")
return jsonify({"message": "Payment service unavailable"}), 503
@billing_bp.route('/webhook', methods=['POST'])
async def stripe_webhook():
payload = await request.get_data()

View file

@ -4,7 +4,6 @@ from datetime import datetime, timezone
from quart import Blueprint, jsonify, request
from app.auth.quart_jwt import jwt_required, get_jwt_identity
from app.utils import active_required
from app.models.usage_event import UsageEvent
from app.db import get_db
logger = logging.getLogger(__name__)
@ -19,41 +18,38 @@ def _month_start() -> datetime:
@usage_bp.route('/me', methods=['GET'])
@jwt_required()
async def my_usage():
"""GET /api/usage/me — current user's own MTD cost summary."""
"""GET /api/usage/me — current user's credit balance + MTD spend + recent transactions."""
user_id = get_jwt_identity()
try:
period_start = _month_start()
db = await get_db()
pipeline = [
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
{'$group': {
'_id': None,
'total_cost': {'$sum': '$cost_usd.total'},
'prompt_tokens': {'$sum': '$prompt_tokens'},
'completion_tokens': {'$sum': '$completion_tokens'},
'calls': {'$sum': 1},
}},
]
agg = await db.usage_events.aggregate(pipeline).to_list(1)
totals = agg[0] if agg else {'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0}
totals.pop('_id', None)
# By feature
feat_pipeline = [
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
{'$group': {
'_id': '$feature',
'total_cost': {'$sum': '$cost_usd.total'},
'calls': {'$sum': 1},
}},
{'$sort': {'total_cost': -1}},
from app.models.user import User
user = await User.find_by_id(user_id)
credits_balance = user.get('credits_balance', 0) if user else 0
# Credits spent this month (debit transactions only)
credits_pipeline = [
{'$match': {'user_id': user_id, 'type': 'debit', 'ts': {'$gte': period_start}}},
{'$group': {'_id': None, 'credits_spent': {'$sum': {'$abs': '$amount'}}}},
]
by_feature = await db.usage_events.aggregate(feat_pipeline).to_list(20)
credits_agg = await db.credit_transactions.aggregate(credits_pipeline).to_list(1)
credits_spent = credits_agg[0]['credits_spent'] if credits_agg else 0
# Recent transactions (last 30, all types)
tx_cursor = db.credit_transactions.find(
{'user_id': user_id}
).sort('ts', -1).limit(30)
transactions = await tx_cursor.to_list(30)
for tx in transactions:
tx['_id'] = str(tx['_id'])
tx['ts'] = tx['ts'].isoformat() if hasattr(tx.get('ts'), 'isoformat') else tx.get('ts')
from app.utils import make_serializable
return jsonify({
'totals': make_serializable(totals),
'by_feature': make_serializable(by_feature),
'credits_balance': credits_balance,
'credits_spent': credits_spent,
'transactions': make_serializable(transactions),
'period_start': period_start.isoformat(),
}), 200
except Exception as e:

View file

@ -34,7 +34,7 @@ async def create_checkout_session(
line_items=[{
"price_data": {
"currency": "usd",
"unit_amount": price_usd * 100, # cents
"unit_amount": int(price_usd * 100), # cents, must be int
"product_data": {
"name": f"Cohorta {pack_name}{credits} credits",
},

View file

@ -1,11 +1,11 @@
import { useState, useEffect } from 'react';
import { adminApi } from '@/lib/api';
import { adminApi, billingApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Loader2, Save } from 'lucide-react';
import { Loader2, Save, FlaskConical } from 'lucide-react';
interface CreditPack {
id: string;
@ -25,6 +25,8 @@ export default function CreditSettingsTab() {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testCredits, setTestCredits] = useState('10');
const [testLoading, setTestLoading] = useState(false);
useEffect(() => {
adminApi.getSettings()
@ -33,6 +35,20 @@ export default function CreditSettingsTab() {
.finally(() => setLoading(false));
}, []);
const handleTestCheckout = async () => {
const n = parseInt(testCredits, 10);
if (!n || n < 1) { toastService.error('Enter a valid credit amount'); return; }
setTestLoading(true);
try {
const r = await billingApi.testCheckout(n);
window.location.href = r.data.checkout_url;
} catch {
toastService.error('Failed to create test checkout');
} finally {
setTestLoading(false);
}
};
const handleSave = async () => {
if (!settings) return;
setSaving(true);
@ -125,6 +141,33 @@ export default function CreditSettingsTab() {
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Save Settings
</Button>
<Card className="border-dashed border-yellow-500/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlaskConical className="h-4 w-4 text-yellow-500" />
Test Stripe Checkout
</CardTitle>
<CardDescription>Admin only initiates a real $1 Stripe Checkout to test the payment flow. The specified credits will be granted on success.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-3">
<div className="w-40">
<Label>Credits to grant</Label>
<Input
type="number"
min={1}
value={testCredits}
onChange={e => setTestCredits(e.target.value)}
/>
</div>
<Button variant="outline" onClick={handleTestCheckout} disabled={testLoading}>
{testLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FlaskConical className="mr-2 h-4 w-4" />}
Test Checkout ($1)
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -269,14 +269,17 @@
"usage": {
"title": "Usage",
"subtitle": "Month-to-date since {{date}}",
"stat_total_cost": "Total Cost (MTD)",
"stat_llm_calls": "LLM Calls",
"stat_total_tokens": "Total Tokens",
"by_feature": "By Feature",
"col_feature": "Feature",
"col_cost": "Cost",
"col_calls": "Calls",
"no_data": "No usage data yet this month."
"stat_balance": "Credit Balance",
"stat_credits_spent": "Credits Spent (MTD)",
"transactions": "Transactions",
"col_date": "Date",
"col_description": "Description",
"col_amount": "Amount",
"tx_purchase": "Credit purchase",
"tx_grant": "Credits granted",
"tx_refund": "Refund",
"buy_credits": "Buy Credits",
"no_data": "No transactions yet."
},
"billing": {
"title": "Billing",

View file

@ -269,14 +269,17 @@
"usage": {
"title": "Использование",
"subtitle": "За текущий месяц с {{date}}",
"stat_total_cost": "Общая стоимость (МТД)",
"stat_llm_calls": "Вызовы LLM",
"stat_total_tokens": "Всего токенов",
"by_feature": "По функции",
"col_feature": "Функция",
"col_cost": "Стоимость",
"col_calls": "Вызовы",
"no_data": "Данных об использовании за этот месяц пока нет."
"stat_balance": "Баланс кредитов",
"stat_credits_spent": "Потрачено кредитов (МТД)",
"transactions": "Транзакции",
"col_date": "Дата",
"col_description": "Описание",
"col_amount": "Сумма",
"tx_purchase": "Покупка кредитов",
"tx_grant": "Начисление кредитов",
"tx_refund": "Возврат",
"buy_credits": "Купить кредиты",
"no_data": "Транзакций пока нет."
},
"billing": {
"title": "Биллинг",

View file

@ -269,14 +269,17 @@
"usage": {
"title": "Використання",
"subtitle": "За поточний місяць з {{date}}",
"stat_total_cost": "Загальна вартість (МТД)",
"stat_llm_calls": "Виклики LLM",
"stat_total_tokens": "Всього токенів",
"by_feature": "За функцією",
"col_feature": "Функція",
"col_cost": "Вартість",
"col_calls": "Виклики",
"no_data": "Даних про використання за цей місяць поки немає."
"stat_balance": "Баланс кредитів",
"stat_credits_spent": "Витрачено кредитів (МТД)",
"transactions": "Транзакції",
"col_date": "Дата",
"col_description": "Опис",
"col_amount": "Сума",
"tx_purchase": "Купівля кредитів",
"tx_grant": "Нарахування кредитів",
"tx_refund": "Повернення",
"buy_credits": "Купити кредити",
"no_data": "Транзакцій поки немає."
},
"billing": {
"title": "Білінг",

View file

@ -136,6 +136,7 @@ export const billingApi = {
getBalance: () => api.get('/billing/balance'),
getTransactions: (limit = 50) => api.get(`/billing/transactions?limit=${limit}`),
createCheckout: (packId: string) => api.post('/billing/checkout', { pack_id: packId }),
testCheckout: (credits: number) => api.post('/billing/test-checkout', { credits }),
};
// Personas endpoints

View file

@ -1,22 +1,53 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useMyUsage } from '@/hooks/useMyUsage';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, DollarSign, Zap, Activity } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Loader2, Coins, TrendingDown, ShoppingCart } from 'lucide-react';
function txLabel(tx: any, t: (k: string) => string): string {
if (tx.description) return tx.description;
switch (tx.type) {
case 'purchase': return t('usage.tx_purchase');
case 'grant':
case 'admin_grant': return t('usage.tx_grant');
case 'refund': return t('usage.tx_refund');
default: return tx.type ?? '—';
}
}
function txAmount(tx: any) {
const n = tx.amount ?? 0;
const abs = Math.abs(n);
const isDebit = tx.type === 'debit' || n < 0;
return { abs, isDebit };
}
export default function MyUsage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data, isLoading } = useMyUsage();
const totals = data?.totals ?? {};
const byFeature: any[] = data?.by_feature ?? [];
const periodStart = data?.period_start ? new Date(data.period_start).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
const creditsBalance: number = data?.credits_balance ?? 0;
const creditsSpent: number = data?.credits_spent ?? 0;
const transactions: any[] = data?.transactions ?? [];
const periodStart = data?.period_start
? new Date(data.period_start).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
: '—';
return (
<div className="min-h-screen bg-background">
<div className="max-w-4xl mx-auto py-10 px-4 sm:px-6 space-y-8">
<div>
<h1 className="font-display font-bold text-3xl text-foreground">{t('usage.title')}</h1>
<p className="text-muted-foreground mt-1">{t('usage.subtitle', { date: periodStart })}</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-display font-bold text-3xl text-foreground">{t('usage.title')}</h1>
<p className="text-muted-foreground mt-1">{t('usage.subtitle', { date: periodStart })}</p>
</div>
<Button onClick={() => navigate('/billing')} className="flex items-center gap-2 shrink-0">
<ShoppingCart className="h-4 w-4" />
{t('usage.buy_credits')}
</Button>
</div>
{isLoading ? (
@ -25,11 +56,10 @@ export default function MyUsage() {
</div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[
{ label: t('usage.stat_total_cost'), value: `$${(totals.total_cost ?? 0).toFixed(4)}`, icon: DollarSign },
{ label: t('usage.stat_llm_calls'), value: (totals.calls ?? 0).toLocaleString(), icon: Activity },
{ label: t('usage.stat_total_tokens'), value: (((totals.prompt_tokens ?? 0) + (totals.completion_tokens ?? 0)) / 1000).toFixed(1) + 'k', icon: Zap },
{ label: t('usage.stat_balance'), value: creditsBalance.toLocaleString() + ' cr', icon: Coins },
{ label: t('usage.stat_credits_spent'), value: creditsSpent.toLocaleString() + ' cr', icon: TrendingDown },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="corner-card p-6 flex items-center gap-4">
<div className="h-10 w-10 rounded-xl bg-primary/15 flex items-center justify-center flex-shrink-0">
@ -44,31 +74,38 @@ export default function MyUsage() {
</div>
<div>
<h2 className="font-display font-semibold text-xl text-foreground mb-4">{t('usage.by_feature')}</h2>
<h2 className="font-display font-semibold text-xl text-foreground mb-4">{t('usage.transactions')}</h2>
<div className="bg-card border border-border rounded-2xl overflow-hidden overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground font-medium">{t('usage.col_feature')}</TableHead>
<TableHead className="text-right text-muted-foreground font-medium">{t('usage.col_cost')}</TableHead>
<TableHead className="text-right text-muted-foreground font-medium">{t('usage.col_calls')}</TableHead>
<TableHead className="text-muted-foreground font-medium">{t('usage.col_date')}</TableHead>
<TableHead className="text-muted-foreground font-medium">{t('usage.col_description')}</TableHead>
<TableHead className="text-right text-muted-foreground font-medium">{t('usage.col_amount')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byFeature.length === 0 && (
{transactions.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground py-10">
{t('usage.no_data')}
</TableCell>
</TableRow>
)}
{byFeature.map((row: any, i: number) => (
<TableRow key={i} className="border-border hover:bg-secondary/30">
<TableCell className="text-sm text-foreground font-medium">{row._id ?? '—'}</TableCell>
<TableCell className="text-right font-mono text-sm text-primary">${(row.total_cost ?? 0).toFixed(6)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">{row.calls}</TableCell>
</TableRow>
))}
{transactions.map((tx: any) => {
const { abs, isDebit } = txAmount(tx);
return (
<TableRow key={tx._id} className="border-border hover:bg-secondary/30">
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{tx.ts ? new Date(tx.ts).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'}
</TableCell>
<TableCell className="text-sm text-foreground">{txLabel(tx, t)}</TableCell>
<TableCell className={`text-right font-mono text-sm font-semibold ${isDebit ? 'text-destructive' : 'text-green-500'}`}>
{isDebit ? '' : '+'}{abs} cr
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>