|
import os |
|
import stripe |
|
import requests |
|
import logging |
|
import pytz |
|
import asyncio |
|
import aiohttp |
|
from fastapi import APIRouter, HTTPException, Header, Query, Depends |
|
from datetime import datetime, timedelta |
|
from dateutil.relativedelta import relativedelta |
|
from typing import Dict, Any, Optional, List, Tuple |
|
from functools import lru_cache |
|
from concurrent.futures import ThreadPoolExecutor |
|
|
|
router = APIRouter() |
|
|
|
|
|
stripe.api_key = os.getenv("STRIPE_KEY") |
|
stripe.api_version = "2023-10-16" |
|
SUPABASE_URL = "https://ussxqnifefkgkaumjann.supabase.co" |
|
SUPABASE_KEY = os.getenv("SUPA_KEY") |
|
|
|
if not stripe.api_key or not SUPABASE_KEY: |
|
raise ValueError("❌ STRIPE_KEY ou SUPA_KEY não foram definidos no ambiente!") |
|
|
|
SUPABASE_HEADERS = { |
|
"apikey": SUPABASE_KEY, |
|
"Authorization": f"Bearer {SUPABASE_KEY}", |
|
"Content-Type": "application/json" |
|
} |
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
thread_pool = ThreadPoolExecutor(max_workers=10) |
|
|
|
|
|
@lru_cache(maxsize=128) |
|
def get_cached_admin_status(user_id: str) -> bool: |
|
"""Obtém e armazena em cache se um usuário é admin""" |
|
user_data_url = f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}" |
|
response = requests.get(user_data_url, headers=SUPABASE_HEADERS) |
|
|
|
if response.status_code != 200 or not response.json(): |
|
return False |
|
|
|
user_info = response.json()[0] |
|
return user_info.get("is_admin", False) |
|
|
|
async def verify_admin_token(user_token: str) -> str: |
|
"""Verifica se o token pertence a um administrador de forma assíncrona""" |
|
headers = { |
|
"Authorization": f"Bearer {user_token}", |
|
"apikey": SUPABASE_KEY, |
|
"Content-Type": "application/json" |
|
} |
|
|
|
async with aiohttp.ClientSession() as session: |
|
|
|
async with session.get(f"{SUPABASE_URL}/auth/v1/user", headers=headers) as response: |
|
if response.status != 200: |
|
raise HTTPException(status_code=401, detail="Token inválido ou expirado") |
|
|
|
user_data = await response.json() |
|
user_id = user_data.get("id") |
|
if not user_id: |
|
raise HTTPException(status_code=400, detail="ID do usuário não encontrado") |
|
|
|
|
|
is_admin = await asyncio.to_thread(get_cached_admin_status, user_id) |
|
|
|
if not is_admin: |
|
raise HTTPException(status_code=403, detail="Acesso negado: privilégios de administrador necessários") |
|
|
|
return user_id |
|
|
|
async def fetch_stripe_data(fetch_func, **params): |
|
"""Executa chamadas ao Stripe em thread separada para não bloquear""" |
|
return await asyncio.to_thread(fetch_func, **params) |
|
|
|
async def get_total_platform_revenue(start_timestamp: Optional[int] = None, end_timestamp: Optional[int] = None) -> Dict[str, Any]: |
|
"""Obtém o faturamento total da plataforma no Stripe de forma otimizada""" |
|
try: |
|
query_params = {"limit": 100} |
|
|
|
|
|
if start_timestamp and end_timestamp: |
|
query_params["created"] = {"gte": start_timestamp, "lte": end_timestamp} |
|
elif start_timestamp: |
|
query_params["created"] = {"gte": start_timestamp} |
|
elif end_timestamp: |
|
query_params["created"] = {"lte": end_timestamp} |
|
|
|
payments = [] |
|
has_more = True |
|
last_id = None |
|
|
|
|
|
while has_more: |
|
if last_id: |
|
query_params["starting_after"] = last_id |
|
|
|
|
|
payment_list = await fetch_stripe_data(stripe.Charge.list, **query_params) |
|
payments.extend(payment_list.data) |
|
|
|
has_more = payment_list.has_more |
|
if payment_list.data: |
|
last_id = payment_list.data[-1].id |
|
else: |
|
has_more = False |
|
|
|
|
|
totals = {"total": 0, "succeeded": 0, "failed": 0} |
|
|
|
for payment in payments: |
|
amount = payment.amount |
|
totals["total"] += amount |
|
|
|
if payment.status == 'succeeded': |
|
totals["succeeded"] += amount |
|
elif payment.status == 'failed': |
|
totals["failed"] += amount |
|
|
|
return { |
|
"total_revenue": totals["total"], |
|
"successful_revenue": totals["succeeded"], |
|
"failed_revenue": totals["failed"], |
|
"currency": "BRL", |
|
"payment_count": len(payments) |
|
} |
|
|
|
except Exception as e: |
|
logger.error(f"❌ Erro ao obter faturamento total: {str(e)}") |
|
return { |
|
"total_revenue": 0, |
|
"successful_revenue": 0, |
|
"failed_revenue": 0, |
|
"currency": "BRL", |
|
"payment_count": 0, |
|
"error": str(e) |
|
} |
|
|
|
async def get_platform_transfers(start_timestamp: Optional[int] = None, end_timestamp: Optional[int] = None) -> Dict[str, Any]: |
|
"""Obtém o total transferido para estilistas de forma otimizada""" |
|
try: |
|
query_params = {"limit": 100} |
|
|
|
|
|
if start_timestamp and end_timestamp: |
|
query_params["created"] = {"gte": start_timestamp, "lte": end_timestamp} |
|
elif start_timestamp: |
|
query_params["created"] = {"gte": start_timestamp} |
|
elif end_timestamp: |
|
query_params["created"] = {"lte": end_timestamp} |
|
|
|
transfers = [] |
|
has_more = True |
|
last_id = None |
|
|
|
|
|
while has_more: |
|
if last_id: |
|
query_params["starting_after"] = last_id |
|
|
|
|
|
transfer_list = await fetch_stripe_data(stripe.Transfer.list, **query_params) |
|
transfers.extend(transfer_list.data) |
|
|
|
has_more = transfer_list.has_more |
|
if transfer_list.data: |
|
last_id = transfer_list.data[-1].id |
|
else: |
|
has_more = False |
|
|
|
|
|
total_transferred = sum(transfer.amount for transfer in transfers) |
|
|
|
return { |
|
"total_transferred_to_stylists": total_transferred, |
|
"currency": "BRL", |
|
"transfer_count": len(transfers) |
|
} |
|
|
|
except Exception as e: |
|
logger.error(f"❌ Erro ao obter transferências: {str(e)}") |
|
return { |
|
"total_transferred_to_stylists": 0, |
|
"currency": "BRL", |
|
"transfer_count": 0, |
|
"error": str(e) |
|
} |
|
|
|
def get_app_revenue_share(total_revenue: int, total_transferred: int) -> Dict[str, Any]: |
|
"""Calcula a parte do faturamento que ficou com o aplicativo""" |
|
app_revenue = total_revenue - total_transferred |
|
|
|
|
|
if total_revenue > 0: |
|
app_percentage = (app_revenue / total_revenue) * 100 |
|
stylists_percentage = (total_transferred / total_revenue) * 100 |
|
else: |
|
app_percentage = 0 |
|
stylists_percentage = 0 |
|
|
|
return { |
|
"app_revenue": app_revenue, |
|
"app_percentage": round(app_percentage, 2), |
|
"stylists_percentage": round(stylists_percentage, 2), |
|
"currency": "BRL" |
|
} |
|
|
|
async def get_platform_users() -> Dict[str, Any]: |
|
"""Obtém informações sobre usuários, estilistas e assinaturas da plataforma de forma assíncrona""" |
|
try: |
|
async with aiohttp.ClientSession() as session: |
|
|
|
tasks = [ |
|
session.get(f"{SUPABASE_URL}/rest/v1/User?select=id", headers=SUPABASE_HEADERS), |
|
session.get(f"{SUPABASE_URL}/rest/v1/User?role=eq.stylist&select=id", headers=SUPABASE_HEADERS), |
|
session.get(f"{SUPABASE_URL}/rest/v1/Subscriptions?select=id,active", headers=SUPABASE_HEADERS) |
|
] |
|
|
|
responses = await asyncio.gather(*tasks) |
|
users_response, stylists_response, subscriptions_response = responses |
|
|
|
total_users = 0 |
|
if users_response.status == 200: |
|
users_data = await users_response.json() |
|
total_users = len(users_data) |
|
|
|
total_stylists = 0 |
|
if stylists_response.status == 200: |
|
stylists_data = await stylists_response.json() |
|
total_stylists = len(stylists_data) |
|
|
|
|
|
total_subscriptions = 0 |
|
active_subscriptions = 0 |
|
if subscriptions_response.status == 200: |
|
subscriptions_data = await subscriptions_response.json() |
|
total_subscriptions = len(subscriptions_data) |
|
active_subscriptions = sum(1 for sub in subscriptions_data if sub.get("active", False)) |
|
|
|
logger.info(f"Total de usuários: {total_users}, Total de estilistas: {total_stylists}, " |
|
f"Assinaturas ativas: {active_subscriptions}, Total de assinaturas: {total_subscriptions}") |
|
|
|
return { |
|
"total_users": total_users, |
|
"total_stylists": total_stylists, |
|
"active_subscriptions": active_subscriptions, |
|
"total_subscriptions": total_subscriptions |
|
} |
|
|
|
except Exception as e: |
|
logger.error(f"❌ Erro ao obter informações de usuários e assinaturas: {str(e)}") |
|
return { |
|
"total_users": 0, |
|
"total_stylists": 0, |
|
"active_subscriptions": 0, |
|
"total_subscriptions": 0, |
|
"error": str(e) |
|
} |
|
|
|
async def get_top_stylists(start_timestamp: Optional[int] = None, end_timestamp: Optional[int] = None, limit: int = 10) -> List[Dict[str, Any]]: |
|
"""Obtém os estilistas com mais assinantes ativos e totais de forma otimizada""" |
|
try: |
|
|
|
base_url = f"{SUPABASE_URL}/rest/v1/Subscriptions?select=stylist_id,active,created_at" |
|
|
|
|
|
if start_timestamp or end_timestamp: |
|
date_filters = [] |
|
if start_timestamp: |
|
start_date = datetime.fromtimestamp(start_timestamp).strftime("%Y-%m-%d") |
|
date_filters.append(f"created_at=gte.{start_date}") |
|
if end_timestamp: |
|
end_date = datetime.fromtimestamp(end_timestamp).strftime("%Y-%m-%d") |
|
date_filters.append(f"created_at=lte.{end_date}") |
|
|
|
if date_filters: |
|
date_query = "&".join(date_filters) |
|
base_url = f"{base_url}&{date_query}" |
|
|
|
|
|
async with aiohttp.ClientSession() as session: |
|
async with session.get(base_url, headers=SUPABASE_HEADERS) as response: |
|
if response.status != 200: |
|
logger.error(f"❌ Erro ao obter assinaturas: {response.status}") |
|
return [] |
|
|
|
subscriptions_data = await response.json() |
|
|
|
|
|
stylists_stats = {} |
|
for subscription in subscriptions_data: |
|
stylist_id = subscription.get("stylist_id") |
|
is_active = subscription.get("active", False) |
|
|
|
if stylist_id not in stylists_stats: |
|
stylists_stats[stylist_id] = { |
|
"active_count": 0, |
|
"total_count": 0 |
|
} |
|
|
|
|
|
stylists_stats[stylist_id]["total_count"] += 1 |
|
if is_active: |
|
stylists_stats[stylist_id]["active_count"] += 1 |
|
|
|
|
|
if not stylists_stats: |
|
return [] |
|
|
|
|
|
top_stylist_ids = sorted( |
|
stylists_stats.keys(), |
|
key=lambda x: stylists_stats[x]["active_count"], |
|
reverse=True |
|
)[:limit] |
|
|
|
|
|
if not top_stylist_ids: |
|
return [] |
|
|
|
|
|
stylists_url = f"{SUPABASE_URL}/rest/v1/User?id=in.({','.join(top_stylist_ids)})&select=id,name,avatar" |
|
|
|
|
|
headers = SUPABASE_HEADERS.copy() |
|
headers["Accept"] = "application/json; charset=utf-8" |
|
|
|
|
|
async with aiohttp.ClientSession() as session: |
|
async with session.get(stylists_url, headers=headers) as response: |
|
if response.status != 200: |
|
logger.error(f"❌ Erro ao obter detalhes dos estilistas: {response.status}") |
|
return [] |
|
|
|
|
|
text = await response.text(encoding='utf-8') |
|
import json |
|
stylists_data = json.loads(text) |
|
|
|
|
|
stylists_map = {stylist["id"]: stylist for stylist in stylists_data} |
|
|
|
|
|
result = [] |
|
for stylist_id in top_stylist_ids: |
|
if stylist_id in stylists_map: |
|
result.append({ |
|
"id": stylist_id, |
|
"name": stylists_map[stylist_id].get("name", "Nome não disponível"), |
|
"avatar": stylists_map[stylist_id].get("avatar", ""), |
|
"active_subscriptions": stylists_stats[stylist_id]["active_count"], |
|
"total_subscriptions": stylists_stats[stylist_id]["total_count"] |
|
}) |
|
|
|
return result |
|
|
|
except Exception as e: |
|
logger.error(f"❌ Erro ao obter top estilistas: {str(e)}") |
|
return [] |
|
|
|
async def get_monthly_revenue_data(target_date) -> Dict[str, Any]: |
|
"""Obtém o faturamento de um mês específico de forma otimizada""" |
|
ny_timezone = pytz.timezone('America/New_York') |
|
if not isinstance(target_date, datetime): |
|
target_date = datetime.now(ny_timezone) |
|
|
|
|
|
month_start = datetime(target_date.year, target_date.month, 1, tzinfo=ny_timezone) |
|
if target_date.month == 12: |
|
month_end = datetime(target_date.year + 1, 1, 1, tzinfo=ny_timezone) - timedelta(seconds=1) |
|
else: |
|
month_end = datetime(target_date.year, target_date.month + 1, 1, tzinfo=ny_timezone) - timedelta(seconds=1) |
|
|
|
|
|
start_timestamp = int(month_start.timestamp()) |
|
end_timestamp = int(month_end.timestamp()) |
|
|
|
|
|
revenue_data, transfer_data = await asyncio.gather( |
|
get_total_platform_revenue(start_timestamp, end_timestamp), |
|
get_platform_transfers(start_timestamp, end_timestamp) |
|
) |
|
|
|
|
|
total_revenue = revenue_data["successful_revenue"] |
|
total_transferred = transfer_data["total_transferred_to_stylists"] |
|
app_revenue = total_revenue - total_transferred |
|
|
|
return { |
|
"year": target_date.year, |
|
"month": target_date.month, |
|
"name": target_date.strftime("%b"), |
|
"total_revenue": total_revenue, |
|
"app_revenue": app_revenue, |
|
"stylists_revenue": total_transferred, |
|
"payment_count": revenue_data["payment_count"], |
|
"transfer_count": transfer_data["transfer_count"] |
|
} |
|
|
|
|
|
def get_period_timestamps(period: str) -> Tuple[Optional[int], Optional[int]]: |
|
"""Retorna timestamps inicial e final baseados no período especificado""" |
|
ny_timezone = pytz.timezone('America/New_York') |
|
now_ny = datetime.now(ny_timezone) |
|
current_year = now_ny.year |
|
|
|
|
|
start_timestamp = None |
|
end_timestamp = int(now_ny.timestamp()) |
|
|
|
|
|
months = { |
|
'January': 1, 'February': 2, 'March': 3, 'April': 4, |
|
'May': 5, 'June': 6, 'July': 7, 'August': 8, |
|
'September': 9, 'October': 10, 'November': 11, 'December': 12 |
|
} |
|
|
|
if period in months: |
|
|
|
month_num = months[period] |
|
|
|
|
|
if month_num > now_ny.month: |
|
year = current_year - 1 |
|
else: |
|
year = current_year |
|
|
|
|
|
month_start = datetime(year, month_num, 1, tzinfo=ny_timezone) |
|
if month_num == 12: |
|
month_end = datetime(year + 1, 1, 1, tzinfo=ny_timezone) - timedelta(seconds=1) |
|
else: |
|
month_end = datetime(year, month_num + 1, 1, tzinfo=ny_timezone) - timedelta(seconds=1) |
|
|
|
start_timestamp = int(month_start.timestamp()) |
|
end_timestamp = int(month_end.timestamp()) |
|
|
|
elif period == "All Year": |
|
|
|
year_start = datetime(current_year, 1, 1, tzinfo=ny_timezone) |
|
year_end = datetime(current_year + 1, 1, 1, tzinfo=ny_timezone) - timedelta(seconds=1) |
|
|
|
start_timestamp = int(year_start.timestamp()) |
|
end_timestamp = int(year_end.timestamp()) |
|
|
|
|
|
if now_ny.month <= 2: |
|
year_start = datetime(current_year - 1, 1, 1, tzinfo=ny_timezone) |
|
start_timestamp = int(year_start.timestamp()) |
|
|
|
|
|
elif period == "last_month": |
|
start_date = now_ny - relativedelta(months=1) |
|
start_timestamp = int(start_date.timestamp()) |
|
elif period == "last_year": |
|
start_date = now_ny - relativedelta(years=1) |
|
start_timestamp = int(start_date.timestamp()) |
|
|
|
return start_timestamp, end_timestamp |
|
|
|
|
|
def is_specific_month(period: str) -> bool: |
|
"""Verifica se o período solicitado é um mês específico""" |
|
months = [ |
|
'January', 'February', 'March', 'April', 'May', 'June', |
|
'July', 'August', 'September', 'October', 'November', 'December' |
|
] |
|
return period in months |
|
|
|
@router.get("/admin/dashboard") |
|
async def get_admin_dashboard( |
|
user_token: str = Header(None, alias="User-key"), |
|
period: str = Query("All Year", description="Período: All Year, January, February, etc.") |
|
): |
|
""" |
|
Endpoint para dashboard administrativo com métricas de faturamento |
|
e divisão entre app e estilistas - versão otimizada com seleção de meses |
|
""" |
|
try: |
|
|
|
user_id = await verify_admin_token(user_token) |
|
|
|
|
|
start_timestamp, end_timestamp = get_period_timestamps(period) |
|
|
|
|
|
tasks = [ |
|
get_total_platform_revenue(start_timestamp, end_timestamp), |
|
get_platform_transfers(start_timestamp, end_timestamp), |
|
get_platform_users(), |
|
get_top_stylists(start_timestamp, end_timestamp, 10) |
|
] |
|
|
|
|
|
monthly_task = None |
|
monthly_data = None |
|
|
|
if is_specific_month(period): |
|
|
|
ny_timezone = pytz.timezone('America/New_York') |
|
now_ny = datetime.now(ny_timezone) |
|
current_year = now_ny.year |
|
|
|
|
|
months = { |
|
'January': 1, 'February': 2, 'March': 3, 'April': 4, |
|
'May': 5, 'June': 6, 'July': 7, 'August': 8, |
|
'September': 9, 'October': 10, 'November': 11, 'December': 12 |
|
} |
|
month_num = months[period] |
|
|
|
|
|
if month_num > now_ny.month: |
|
year = current_year - 1 |
|
else: |
|
year = current_year |
|
|
|
target_date = datetime(year, month_num, 1, tzinfo=ny_timezone) |
|
monthly_task = get_monthly_revenue_data(target_date) |
|
tasks.append(monthly_task) |
|
|
|
|
|
results = await asyncio.gather(*tasks) |
|
|
|
|
|
if is_specific_month(period): |
|
revenue_data, transfer_data, user_data, top_stylists, monthly_data = results |
|
else: |
|
revenue_data, transfer_data, user_data, top_stylists = results |
|
|
|
|
|
revenue_share = get_app_revenue_share( |
|
revenue_data["successful_revenue"], |
|
transfer_data["total_transferred_to_stylists"] |
|
) |
|
|
|
|
|
response = { |
|
"total_revenue": revenue_data["successful_revenue"], |
|
"failed_revenue": revenue_data["failed_revenue"], |
|
"total_transferred_to_stylists": transfer_data["total_transferred_to_stylists"], |
|
"app_revenue": revenue_share["app_revenue"], |
|
"app_revenue_percentage": revenue_share["app_percentage"], |
|
"stylists_revenue_percentage": revenue_share["stylists_percentage"], |
|
"payment_count": revenue_data["payment_count"], |
|
"transfer_count": transfer_data["transfer_count"], |
|
"currency": "BRL", |
|
"period": period, |
|
"users": user_data, |
|
"top_stylists": top_stylists |
|
} |
|
|
|
|
|
if is_specific_month(period) and monthly_data: |
|
response["monthly_data"] = monthly_data |
|
|
|
|
|
if period == "All Year": |
|
|
|
ny_timezone = pytz.timezone('America/New_York') |
|
now_ny = datetime.now(ny_timezone) |
|
current_year = now_ny.year |
|
|
|
monthly_tasks = [] |
|
for month in range(1, 13): |
|
|
|
if month <= now_ny.month: |
|
target_date = datetime(current_year, month, 1, tzinfo=ny_timezone) |
|
monthly_tasks.append(get_monthly_revenue_data(target_date)) |
|
|
|
if monthly_tasks: |
|
monthly_results = await asyncio.gather(*monthly_tasks) |
|
response["year_data"] = monthly_results |
|
|
|
return response |
|
|
|
except HTTPException as he: |
|
raise he |
|
|
|
except Exception as e: |
|
logger.error(f"❌ Erro ao gerar dashboard administrativo: {str(e)}") |
|
raise HTTPException(status_code=500, detail=str(e)) |