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:
parent
d92a099ade
commit
bac7e7bce3
9 changed files with 198 additions and 77 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Биллинг",
|
||||
|
|
|
|||
|
|
@ -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": "Білінг",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue