connect / routes /subscription.py
habulaj's picture
Update routes/subscription.py
c919ade verified
raw
history blame
28.2 kB
import stripe
import logging
import json
from datetime import datetime
import pytz
import os
import requests
import asyncio
import jwt
from fastapi import APIRouter, HTTPException, Request, Header
from pydantic import BaseModel
router = APIRouter()
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# 🔥 Pegando as chaves do ambiente
stripe.api_key = os.getenv("STRIPE_KEY") # Lendo do ambiente
stripe.api_version = "2023-10-16"
# 🔥 Supabase Configuração com Secrets
SUPABASE_URL = "https://ussxqnifefkgkaumjann.supabase.co"
SUPABASE_KEY = os.getenv("SUPA_KEY") # Lendo do ambiente
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"
}
class CheckSubscriptionRequest(BaseModel):
stylist_id: str
user_token: str = Header(None, alias="User-key")
class SubscriptionRequest(BaseModel):
id: str # ID do estilista
class CreatePriceRequest(BaseModel):
amount: int # Valor em centavos (ex: 2500 para R$25,00)
emergency_price: int # Valor de emergência (ex: 500 para R$5,00)
consultations: int # Número de consultas (ex: 3)
def verify_token(user_token: str) -> str:
"""
Valida o token JWT no Supabase e retorna o user_id se for válido.
"""
headers = {
"Authorization": f"Bearer {user_token}",
"apikey": SUPABASE_KEY,
"Content-Type": "application/json"
}
response = requests.get(f"{SUPABASE_URL}/auth/v1/user", headers=headers)
if response.status_code == 200:
user_data = response.json()
user_id = user_data.get("id")
if not user_id:
raise HTTPException(status_code=400, detail="Invalid token: User ID not found")
return user_id
else:
raise HTTPException(status_code=401, detail="Invalid or expired token")
@router.get("/subscription/status/{subscription_id}")
async def check_subscription_status(subscription_id: str):
try:
# 🔹 Consulta a Stripe diretamente
stripe_url = f"https://api.stripe.com/v1/subscriptions/{subscription_id}"
headers = {
"Authorization": f"Bearer {stripe.api_key}" # ✅ Usa a chave definida no ambiente
}
response = requests.get(stripe_url, headers=headers)
if response.status_code != 200:
raise HTTPException(status_code=404, detail="Subscription not found in Stripe")
subscription = response.json()
# 🔹 Pega os dados necessários
status = subscription.get("status")
cancel_at_period_end = subscription.get("cancel_at_period_end", False)
canceled_at = subscription.get("canceled_at")
current_period_end = subscription.get("current_period_end")
# 🔹 Converte timestamps para data legível
ny_tz = pytz.timezone("America/New_York")
canceled_at_date = (
datetime.utcfromtimestamp(canceled_at).replace(tzinfo=pytz.utc).astimezone(ny_tz).isoformat()
if canceled_at else None
)
expiration_date = (
datetime.utcfromtimestamp(current_period_end).replace(tzinfo=pytz.utc).astimezone(ny_tz).isoformat()
if current_period_end else None
)
return {
"subscription_id": subscription_id,
"status": status,
"cancel_at_period_end": cancel_at_period_end,
"canceled_at": canceled_at_date,
"expiration_date": expiration_date
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webhook")
async def stripe_webhook(request: Request):
try:
payload = await request.json()
event_type = payload.get("type")
if event_type == "invoice.payment_succeeded":
invoice = payload.get("data", {}).get("object", {})
# 🔹 Capturar valores da fatura
amount_paid = invoice.get("amount_paid", 0) # Valor total pago (em centavos)
currency = invoice.get("currency", "usd") # Moeda do pagamento
# 🔹 Buscando metadados
metadata = invoice.get("metadata", {})
subscription_metadata = invoice.get("subscription_details", {}).get("metadata", {})
line_items = invoice.get("lines", {}).get("data", [])
line_item_metadata = line_items[0].get("metadata", {}) if line_items else {}
# 🔹 Pegando os valores corretos
stylist_id = metadata.get("stylist_id") or subscription_metadata.get("stylist_id") or line_item_metadata.get("stylist_id")
user_id = metadata.get("user_id") or subscription_metadata.get("user_id") or line_item_metadata.get("user_id")
price_id = metadata.get("price_id") or subscription_metadata.get("price_id") or line_item_metadata.get("price_id")
subscription_id = invoice.get("subscription") # Capturando o ID da assinatura
# 🔹 Calculando a divisão do pagamento
stylist_amount = int(amount_paid * 0.8) # 80% para o estilista
platform_amount = int(amount_paid * 0.2) # 20% para a plataforma
# 🔹 Logando as informações detalhadas
logger.info(f"✅ Pagamento bem-sucedido! Valor total: R$ {amount_paid / 100:.2f}")
logger.info(f"👤 Stylist ID: {stylist_id}")
logger.info(f"👥 User ID: {user_id}")
logger.info(f"💰 Estilista recebe: R$ {stylist_amount / 100:.2f}")
logger.info(f"🏛️ Plataforma fica com: R$ {platform_amount / 100:.2f}")
# 🔹 Inserir dados na tabela Subscriptions no Supabase
subscription_data = {
"stylist_id": stylist_id,
"customer_id": user_id,
"active": True, # Assinatura ativa após pagamento
"sub_id": subscription_id, # ID da assinatura
"price_id": price_id # ID do preço
}
supabase_headers = {
"Authorization": f"Bearer {SUPABASE_KEY}",
"apikey": SUPABASE_KEY,
"Content-Type": "application/json"
}
subscription_url = f"{SUPABASE_URL}/rest/v1/Subscriptions"
response_subscription = requests.post(
subscription_url,
headers=supabase_headers,
json=subscription_data
)
if response_subscription.status_code == 201:
logger.info(f"✅ Subscription added successfully for user {user_id}")
else:
logger.error(f"❌ Failed to add subscription: {response_subscription.status_code} - {response_subscription.text}")
return {
"status": "success",
"total_paid": amount_paid / 100,
"stylist_id": stylist_id,
"user_id": user_id,
"stylist_amount": stylist_amount / 100,
"platform_amount": platform_amount / 100
}
elif event_type == "customer.subscription.deleted":
subscription = payload.get("data", {}).get("object", {})
subscription_id = subscription.get("id")
if not subscription_id:
logger.error("❌ Subscription ID not found in event payload.")
return {"status": "error", "message": "Subscription ID missing."}
logger.info(f"🔹 Subscription {subscription_id} canceled. Updating database...")
# 🔹 Atualizar active = False no Supabase
update_url = f"{SUPABASE_URL}/rest/v1/Subscriptions?sub_id=eq.{subscription_id}"
update_data = {"active": False}
supabase_headers = {
"Authorization": f"Bearer {SUPABASE_KEY}",
"apikey": SUPABASE_KEY,
"Content-Type": "application/json"
}
response_update = requests.patch(update_url, headers=supabase_headers, json=update_data)
if response_update.status_code in [200, 204]:
logger.info(f"✅ Subscription {subscription_id} marked as inactive in Supabase.")
return {"status": "success", "message": "Subscription canceled and updated."}
else:
logger.error(f"❌ Failed to update subscription: {response_update.status_code} - {response_update.text}")
return {"status": "error", "message": "Failed to update subscription."}
if event_type == "customer.subscription.updated":
subscription = payload.get("data", {}).get("object", {})
subscription_id = subscription.get("id")
canceled_at = subscription.get("canceled_at") # Timestamp do cancelamento
cancel_status = subscription.get("cancel_at_period_end", False)
if canceled_at:
# Convertendo timestamp para data e horário de Nova York
ny_tz = pytz.timezone("America/New_York")
canceled_date = datetime.fromtimestamp(canceled_at, ny_tz).isoformat()
logger.info(f"🔹 Subscription {subscription_id} canceled at {canceled_date} (New York Time).")
# 🔹 Atualizando Supabase
update_url = f"{SUPABASE_URL}/rest/v1/Subscriptions?sub_id=eq.{subscription_id}"
update_data = {
"canceled": True,
"canceled_date": canceled_date
}
supabase_headers = {
"Authorization": f"Bearer {SUPABASE_KEY}",
"apikey": SUPABASE_KEY,
"Content-Type": "application/json"
}
response_update = requests.patch(update_url, headers=supabase_headers, json=update_data)
if response_update.status_code in [200, 204]:
logger.info(f"✅ Subscription {subscription_id} updated with canceled date.")
return {"status": "success", "message": "Subscription updated with cancellation date."}
else:
logger.error(f"❌ Failed to update subscription: {response_update.status_code} - {response_update.text}")
return {"status": "error", "message": "Failed to update subscription."}
else:
logger.info(f"🔹 Subscription {subscription_id} updated, but not canceled.")
return {"status": "success", "message": "Subscription updated but not canceled."}
except Exception as e:
logger.error(f"❌ Erro no webhook: {str(e)}")
return {"status": "error", "message": str(e)}
@router.post("/create_price")
async def create_price(
data: CreatePriceRequest,
user_token: str = Header(None, alias="User-key")
):
try:
if not user_token:
raise HTTPException(status_code=401, detail="Missing User-key header")
# 🔹 1. Validar o token e obter user_id
user_id = verify_token(user_token)
logger.info(f"🔹 User verified. user_id: {user_id}")
amount = data.amount
emergency_price = data.emergency_price
consultations = data.consultations
# 🔹 2. Verificar se os valores estão dentro dos limites permitidos
if not (500 <= amount <= 99900):
raise HTTPException(status_code=400, detail="Amount must be between $5 and $999")
if not (500 <= emergency_price <= 99900):
raise HTTPException(status_code=400, detail="Emergency price must be between $5 and $999")
# 🔹 3. Consultations precisa ser 0 obrigatoriamente se não for definido
if consultations is None:
consultations = 0
elif consultations < 0:
raise HTTPException(status_code=400, detail="Consultations must be greater than or equal to 0")
logger.info(f"🔹 Validated amounts: amount = {amount}, emergency_price = {emergency_price}, consultations = {consultations}")
# 🔹 4. Buscar price_id, name, avatar e bio do usuário no Supabase
supabase_url = f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}"
headers = {
"Authorization": f"Bearer {user_token}", # 🔹 Incluindo token correto no header
**SUPABASE_HEADERS
}
response = requests.get(supabase_url, headers=headers)
logger.info(f"🔹 Supabase GET response: {response.status_code} - {response.text}")
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Failed to fetch user from Supabase: {response.text}")
user_data = response.json()
if not user_data:
raise HTTPException(status_code=404, detail="User not found in Supabase")
user = user_data[0]
existing_price_id = user.get("price_id")
user_name = user.get("name", "Unknown User") # 🔹 Recuperar o nome do usuário
user_avatar = user.get("avatar", None) # 🔹 Recuperar a URL do avatar do usuário, se existir
user_bio = user.get("bio", "No description provided.") # 🔹 Recuperar a bio, com padrão se estiver vazia
logger.info(f"🔹 Existing price_id: {existing_price_id}, user_name: {user_name}, user_avatar: {user_avatar}, user_bio: {user_bio}")
# 🔹 5. Criar produto no Stripe com nome, imagem e descrição baseados no usuário
product_data = {
"name": f"{user_name} - {amount} BRL", # Nome do produto
"description": user_bio, # Descrição do produto, com fallback se vazia
}
if user_avatar: # Verificar se o avatar existe e adicionar a imagem
product_data["images"] = [user_avatar]
product = stripe.Product.create(**product_data)
product_id = product.id
logger.info(f"✅ New product created: {product_id}")
# 🔹 6. Criar novo preço no Stripe associado ao produto
price = stripe.Price.create(
unit_amount=amount,
currency="brl",
recurring={"interval": "month"},
product=product_id # Associando o preço ao produto
)
new_price_id = price.id
logger.info(f"✅ New price created: {new_price_id}")
# 🔹 7. Se já houver um price_id, cancelar assinaturas ativas ao final do período
if existing_price_id:
subscriptions = stripe.Subscription.list(status="active")
for sub in subscriptions.auto_paging_iter():
if sub["items"]["data"][0]["price"]["id"] == existing_price_id:
stripe.Subscription.modify(sub.id, cancel_at_period_end=True)
logger.info(f"🔹 Subscription {sub.id} set to cancel at period end.")
# 🔹 8. Atualizar Supabase com o novo price_id e valores adicionais
update_data = {
"price_id": new_price_id, # Novo price_id
"price": amount, # Atualizando o valor de 'price'
"emergency_price": emergency_price, # Atualizando o valor de 'emergency_price'
"consultations": consultations # Atualizando o valor de 'consultations'
}
# Aqui estamos incluindo o cabeçalho de autenticação com o token JWT correto
update_headers = {
"Authorization": f"Bearer {user_token}", # Incluindo token correto no header
"apikey": SUPABASE_KEY,
"Content-Type": "application/json"
}
update_response = requests.patch(supabase_url, headers=update_headers, json=update_data)
# Log detalhado para verificar a resposta
logger.info(f"🔹 Supabase PATCH response: {update_response.status_code} - {update_response.text}")
if update_response.status_code not in [200, 204]:
raise HTTPException(status_code=500, detail=f"Failed to update Supabase: {update_response.text}")
logger.info(f"✅ Successfully updated user {user_id} with new price_id, price, emergency_price, and consultations")
return {"message": "Price created and user updated successfully!", "price_id": new_price_id}
except Exception as e:
logger.error(f"❌ Error creating price: {e}")
raise HTTPException(status_code=500, detail=f"Error creating price: {str(e)}")
@router.post("/create_checkout_session")
def create_checkout_session(
data: SubscriptionRequest,
user_token: str = Header(None, alias="User-key")
):
try:
if not user_token:
raise HTTPException(status_code=401, detail="Missing User-key header")
# 🔹 1. Validar o token do cliente e obter user_id (cliente)
user_id = verify_token(user_token)
# 🔹 2. Buscar dados do estilista no Supabase
response_stylist = requests.get(
f"{SUPABASE_URL}/rest/v1/User?id=eq.{data.id}",
headers=SUPABASE_HEADERS
)
stylist_data = response_stylist.json()
if not stylist_data:
raise HTTPException(status_code=404, detail="Stylist not found")
stylist = stylist_data[0]
stylist_id = stylist.get("id") # ID do estilista no Supabase
stylist_stripe_id = stylist.get("stripe_id") # ID do estilista no Stripe
price_id = stylist.get("price_id") # ID do preço no Stripe
if not stylist_stripe_id or not price_id:
raise HTTPException(status_code=400, detail="Stylist profile is incomplete")
# 🔹 3. Buscar dados do cliente no Supabase
response_user = requests.get(
f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}",
headers=SUPABASE_HEADERS
)
user_data = response_user.json()
if not user_data:
raise HTTPException(status_code=404, detail="Client not found")
user = user_data[0]
user_stripe_id = user.get("stripe_id") # ID do cliente no Stripe
if not user_stripe_id:
raise HTTPException(status_code=400, detail="Client does not have a Stripe Customer ID")
# 🔹 Buscar detalhes do preço no Stripe
price_details = stripe.Price.retrieve(price_id)
stylist_price = price_details.unit_amount # Obtém o valor do preço em centavos
# 🔹 Criar Checkout Session no Stripe (Sem `payment_intent_data`)
session = stripe.checkout.Session.create(
success_url="https://yourdomain.com/success",
cancel_url="https://yourdomain.com/cancel",
payment_method_types=["card"],
mode="subscription",
customer=user_stripe_id,
line_items=[
{
"price": price_id,
"quantity": 1
}
],
subscription_data={ # 🔹 Aplicando divisão de receita via `application_fee_percent`
"application_fee_percent": 20, # 20% para a plataforma
"transfer_data": {
"destination": stylist_stripe_id # Conta do estilista no Stripe
},
"metadata": {
"stylist_id": stylist_id,
"stylist_stripe_id": stylist_stripe_id,
"user_id": user_id,
"user_stripe_id": user_stripe_id,
"price_id": price_id
}
},
metadata={ # 🔹 Adicionando metadados no checkout
"stylist_id": stylist_id,
"stylist_stripe_id": stylist_stripe_id,
"user_id": user_id,
"user_stripe_id": user_stripe_id,
"price_id": price_id
}
)
logger.info(f"📌 Checkout session created successfully: {session.url}")
return {"message": "Checkout session created successfully!", "checkout_url": session.url}
except Exception as e:
logger.error(f"Error creating checkout session: {e}")
raise HTTPException(status_code=500, detail="Error creating checkout session.")
### **CANCELAMENTO DE ASSINATURA**
class CancelSubscriptionRequest(BaseModel):
subscription_id: str
@router.post("/cancel_subscription")
def cancel_subscription(data: CancelSubscriptionRequest):
try:
subscription = stripe.Subscription.modify(
data.subscription_id,
cancel_at_period_end=True,
)
return {"status": "Subscription will be canceled at period end"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/check_subscription")
async def check_subscription(
data: CheckSubscriptionRequest,
user_token: str = Header(None, alias="User-key")
):
try:
if not user_token:
raise HTTPException(status_code=401, detail="Missing User-key header")
# 🔹 Validar o token e obter user_id do cliente
user_id = verify_token(user_token)
# 🔹 Buscar o stripe_id do usuário no Supabase
response_user = await async_request(
f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}",
SUPABASE_HEADERS
)
user_data = response_user.json()
if not user_data:
raise HTTPException(status_code=404, detail="User not found")
user_stripe_id = user_data[0].get("stripe_id")
if not user_stripe_id:
raise HTTPException(status_code=404, detail="Stripe customer not found for user")
# 🔹 Buscar todas as assinaturas do cliente (ativas, canceladas, expiradas, etc.)
subscriptions = await async_stripe_request(
stripe.Subscription.list,
customer=user_stripe_id,
expand=["data.items"]
)
logger.info(f"Stripe response data: {subscriptions}")
if not subscriptions or not subscriptions["data"]:
# Se não houver assinaturas para o cliente, desativa as assinaturas associadas ao estilista no banco de dados
logger.info(f"No active subscription found for user {user_id}. Deactivating all subscriptions for stylist {data.stylist_id}.")
# 🔹 Buscar todas as assinaturas do estilista no banco de dados para este cliente
response_subscriptions = await async_request(
f"{SUPABASE_URL}/rest/v1/Subscriptions?customer_id=eq.{user_id}&stylist_id=eq.{data.stylist_id}",
SUPABASE_HEADERS
)
if response_subscriptions.status_code == 200:
subscriptions_data = response_subscriptions.json()
await asyncio.gather(
*(update_subscription_status(sub['id'], False) for sub in subscriptions_data)
)
else:
logger.error(f"❌ Failed to fetch subscriptions from Supabase for stylist {data.stylist_id} and user {user_id}.")
return {"status": "inactive", "message": "No active subscription found for stylist."}
# 🔹 Verifica se existe uma assinatura ativa para o estilista
stylist_found = False
for subscription in subscriptions["data"]:
if subscription["metadata"].get("stylist_id") == data.stylist_id:
stylist_found = True
subscription_id = subscription["id"]
price_id = subscription["items"]["data"][0]["price"]["id"]
status = subscription.get("status")
cancel_at_period_end = subscription.get("cancel_at_period_end", False)
canceled_at = subscription.get("canceled_at")
current_period_end = subscription.get("current_period_end")
# 🔹 Converte timestamps para data legível
nyc_tz = pytz.timezone('America/New_York')
canceled_at_date = (
datetime.utcfromtimestamp(canceled_at).replace(tzinfo=pytz.utc).astimezone(nyc_tz).isoformat()
if canceled_at else None
)
expiration_date = (
datetime.utcfromtimestamp(current_period_end).replace(tzinfo=pytz.utc).astimezone(nyc_tz).isoformat()
if current_period_end else None
)
# 🔹 Atualiza o banco de dados Supabase com os dados da assinatura
subscription_data = {
"stylist_id": data.stylist_id,
"customer_id": user_id,
"active": status == "active",
"sub_id": subscription_id,
"price_id": price_id,
"canceled": cancel_at_period_end or (status == "canceled"),
"canceled_date": canceled_at_date
}
await async_request(
f"{SUPABASE_URL}/rest/v1/Subscriptions",
SUPABASE_HEADERS,
json=subscription_data,
method='POST'
)
# Retorna informações sobre a assinatura sincronizada
return {
"status": status,
"subscription_id": subscription_id,
"consultations_per_month": subscription["metadata"].get("consultations_per_month", None),
"cancel_at_period_end": cancel_at_period_end,
"canceled_at": canceled_at_date,
"expiration_date": expiration_date
}
# 🔹 Caso não tenha encontrado assinatura ativa para o estilista
if not stylist_found:
logger.info(f"No active subscription found for stylist {data.stylist_id}. Deactivating all subscriptions for this stylist.")
# 🔹 Buscar todas as assinaturas do estilista no banco de dados
response_subscriptions = await async_request(
f"{SUPABASE_URL}/rest/v1/Subscriptions?stylist_id=eq.{data.stylist_id}",
SUPABASE_HEADERS
)
if response_subscriptions.status_code == 200:
subscriptions_data = response_subscriptions.json()
await asyncio.gather(
*(update_subscription_status(sub['id'], False) for sub in subscriptions_data)
)
else:
logger.error(f"❌ Failed to fetch subscriptions from Supabase for stylist {data.stylist_id}.")
return {"status": "inactive", "message": "No active subscription found for stylist."}
except stripe.error.StripeError as e:
logger.error(f"Stripe error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Stripe error: {str(e)}")
except Exception as e:
logger.error(f"Error checking subscription: {str(e)}")
raise HTTPException(status_code=500, detail="Error checking subscription.")
# Função assíncrona para fazer requisições de rede
async def async_request(url, headers, json=None, method='GET'):
method_func = requests.post if method == 'POST' else requests.get
response = method_func(url, headers=headers, json=json)
return response
# Função assíncrona para chamar a Stripe API
async def async_stripe_request(func, **kwargs):
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, lambda: func(**kwargs))
return response
# Função assíncrona para atualizar o status de uma assinatura
async def update_subscription_status(subscription_id, status):
update_data = {
"active": status
}
subscription_url = f"{SUPABASE_URL}/rest/v1/Subscriptions?id=eq.{subscription_id}"
update_response = await async_request(
subscription_url,
SUPABASE_HEADERS,
json=update_data,
method='PATCH'
)
if update_response.status_code == 200:
logger.info(f"✅ Subscription {subscription_id} deactivated successfully.")
else:
logger.error(f"❌ Failed to deactivate subscription {subscription_id}.")