diff --git a/backend/app/routes/billing.py b/backend/app/routes/billing.py index ec5b6b90..5ab53fa2 100644 --- a/backend/app/routes/billing.py +++ b/backend/app/routes/billing.py @@ -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() diff --git a/backend/app/routes/usage.py b/backend/app/routes/usage.py index 58118434..d51a721c 100644 --- a/backend/app/routes/usage.py +++ b/backend/app/routes/usage.py @@ -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: diff --git a/backend/app/services/stripe_service.py b/backend/app/services/stripe_service.py index 9f063e85..a66a2a62 100644 --- a/backend/app/services/stripe_service.py +++ b/backend/app/services/stripe_service.py @@ -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", }, diff --git a/src/components/admin/CreditSettingsTab.tsx b/src/components/admin/CreditSettingsTab.tsx index d3a77a67..666a3769 100644 --- a/src/components/admin/CreditSettingsTab.tsx +++ b/src/components/admin/CreditSettingsTab.tsx @@ -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(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 ? : } Save Settings + + + + + + Test Stripe Checkout + + Admin only — initiates a real $1 Stripe Checkout to test the payment flow. The specified credits will be granted on success. + + +
+
+ + setTestCredits(e.target.value)} + /> +
+ +
+
+
); } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f3825fb1..755f4b8b 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -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", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 8219668a..3defda5f 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -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": "Биллинг", diff --git a/src/i18n/locales/uk/common.json b/src/i18n/locales/uk/common.json index 9c294d9d..0ca73308 100644 --- a/src/i18n/locales/uk/common.json +++ b/src/i18n/locales/uk/common.json @@ -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": "Білінг", diff --git a/src/lib/api.ts b/src/lib/api.ts index 8236060a..3a5e3185 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 diff --git a/src/pages/MyUsage.tsx b/src/pages/MyUsage.tsx index a5f827ea..77967826 100644 --- a/src/pages/MyUsage.tsx +++ b/src/pages/MyUsage.tsx @@ -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 (
-
-

{t('usage.title')}

-

{t('usage.subtitle', { date: periodStart })}

+
+
+

{t('usage.title')}

+

{t('usage.subtitle', { date: periodStart })}

+
+
{isLoading ? ( @@ -25,11 +56,10 @@ export default function MyUsage() {
) : (
-
+
{[ - { 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 }) => (
@@ -44,31 +74,38 @@ export default function MyUsage() {
-

{t('usage.by_feature')}

+

{t('usage.transactions')}

- {t('usage.col_feature')} - {t('usage.col_cost')} - {t('usage.col_calls')} + {t('usage.col_date')} + {t('usage.col_description')} + {t('usage.col_amount')} - {byFeature.length === 0 && ( + {transactions.length === 0 && ( {t('usage.no_data')} )} - {byFeature.map((row: any, i: number) => ( - - {row._id ?? '—'} - ${(row.total_cost ?? 0).toFixed(6)} - {row.calls} - - ))} + {transactions.map((tx: any) => { + const { abs, isDebit } = txAmount(tx); + return ( + + + {tx.ts ? new Date(tx.ts).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'} + + {txLabel(tx, t)} + + {isDebit ? '−' : '+'}{abs} cr + + + ); + })}