|
import stripe |
|
import logging |
|
import json |
|
from datetime import datetime |
|
import pytz |
|
import aiohttp |
|
import os |
|
import requests |
|
import asyncio |
|
import jwt |
|
import hashlib |
|
from fastapi import APIRouter, HTTPException, Request, Header |
|
from pydantic import BaseModel |
|
from google.oauth2 import service_account |
|
from google.auth.transport.requests import Request as GoogleRequest |
|
|
|
router = APIRouter() |
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") |
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
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") |
|
SUPABASE_ROLE_KEY = os.getenv("SUPA_SERVICE_KEY") |
|
|
|
|
|
SERVICE_ACCOUNT_FILE = './closetcoach-2d50b-firebase-adminsdk-fbsvc-7fcccbacb1.json' |
|
FCM_PROJECT_ID = "closetcoach-2d50b" |
|
|
|
if not stripe.api_key or not SUPABASE_KEY or not SUPABASE_ROLE_KEY: |
|
raise ValueError("❌ STRIPE_KEY, SUPA_KEY ou SUPA_SERVICE_KEY não foram definidos no ambiente!") |
|
|
|
SUPABASE_HEADERS = { |
|
"apikey": SUPABASE_KEY, |
|
"Authorization": f"Bearer {SUPABASE_KEY}", |
|
"Content-Type": "application/json" |
|
} |
|
|
|
SUPABASE_ROLE_HEADERS = { |
|
"apikey": SUPABASE_ROLE_KEY, |
|
"Authorization": f"Bearer {SUPABASE_ROLE_KEY}", |
|
"Content-Type": "application/json" |
|
} |
|
|
|
class UpdateSubscriptionRequest(BaseModel): |
|
subscription_id: str |
|
|
|
class EmergencyPaymentRequest(BaseModel): |
|
id: str |
|
|
|
class UserIDRequest(BaseModel): |
|
user_id: str |
|
|
|
class CheckSubscriptionRequest(BaseModel): |
|
stylist_id: str |
|
user_id: str |
|
|
|
class SubscriptionRequest(BaseModel): |
|
id: str |
|
|
|
class CreatePriceRequest(BaseModel): |
|
amount: int |
|
emergency_price: int |
|
consultations: int |
|
|
|
|
|
|
|
def short_collapse_key(keyword: str, sender_id: str, receiver_id: str) -> str: |
|
"""Gera uma chave de colapso curta para notificações""" |
|
raw = f"{keyword}:{sender_id}:{receiver_id}" |
|
return hashlib.sha1(raw.encode()).hexdigest()[:20] |
|
|
|
async def fetch_supabase_async(table: str, select: str, filters: dict, headers=SUPABASE_ROLE_HEADERS): |
|
"""Função assíncrona para buscar dados do Supabase""" |
|
filter_query = '&'.join([f'{k}=eq.{v}' for k, v in filters.items()]) |
|
url = f"{SUPABASE_URL}/rest/v1/{table}?select={select}&{filter_query}&order=created_at.desc" |
|
|
|
async with aiohttp.ClientSession() as session: |
|
async with session.get(url, headers=headers) as resp: |
|
if resp.status != 200: |
|
detail = await resp.text() |
|
raise HTTPException(status_code=500, detail=f"Supabase error: {detail}") |
|
return await resp.json() |
|
|
|
def format_name(full_name: str) -> str: |
|
"""Formata o nome para exibição (Nome + inicial do sobrenome)""" |
|
parts = full_name.strip().split() |
|
if len(parts) == 1: |
|
return parts[0] |
|
return f"{parts[0]} {parts[1][0].upper()}." |
|
|
|
async def get_user_info_async(user_id: str): |
|
"""Busca informações do usuário de forma assíncrona""" |
|
users = await fetch_supabase_async("User", "name,token_fcm", {"id": user_id}) |
|
if not users: |
|
return None |
|
return users[0] |
|
|
|
def get_access_token(): |
|
"""Obtém token de acesso para Firebase Cloud Messaging""" |
|
credentials = service_account.Credentials.from_service_account_file( |
|
SERVICE_ACCOUNT_FILE |
|
) |
|
scoped_credentials = credentials.with_scopes( |
|
['https://www.googleapis.com/auth/firebase.messaging'] |
|
) |
|
scoped_credentials.refresh(GoogleRequest()) |
|
return scoped_credentials.token |
|
|
|
async def send_push_notification(sender_id: str, target_user_id: str, keyword: str = "changeprice"): |
|
"""Envia notificação push para um usuário específico""" |
|
try: |
|
|
|
target_user = await get_user_info_async(target_user_id) |
|
if not target_user or not target_user.get("token_fcm"): |
|
logger.warning(f"⚠️ FCM token not found for user {target_user_id}") |
|
return False |
|
|
|
|
|
actor_info = await get_user_info_async(sender_id) |
|
if not actor_info or not actor_info.get("name"): |
|
logger.warning(f"⚠️ Actor info not found for user {sender_id}") |
|
return False |
|
|
|
actor_name = format_name(actor_info["name"]) |
|
collapse_id = short_collapse_key(keyword, sender_id, target_user_id) |
|
|
|
|
|
title = "⚠️ Subscription Price Changed" |
|
body = f"{actor_name} changed your subscription price. Your subscription was automatically canceled. Please check the chat with {actor_name} for reactivation options and more info." |
|
|
|
|
|
message = { |
|
"notification": { |
|
"title": title, |
|
"body": body, |
|
}, |
|
"token": target_user["token_fcm"], |
|
"android": { |
|
"collapse_key": collapse_id, |
|
"notification": { |
|
"tag": collapse_id |
|
} |
|
}, |
|
"apns": { |
|
"headers": { |
|
"apns-collapse-id": collapse_id |
|
} |
|
} |
|
} |
|
|
|
payload = {"message": message} |
|
|
|
|
|
access_token = get_access_token() |
|
headers = { |
|
"Authorization": f"Bearer {access_token}", |
|
"Content-Type": "application/json" |
|
} |
|
url = f"https://fcm.googleapis.com/v1/projects/{FCM_PROJECT_ID}/messages:send" |
|
|
|
async with aiohttp.ClientSession() as session: |
|
async with session.post(url, headers=headers, json=payload) as resp: |
|
if resp.status == 200: |
|
logger.info(f"✅ Push notification sent to user {target_user_id}") |
|
return True |
|
else: |
|
resp_text = await resp.text() |
|
logger.error(f"❌ FCM error for user {target_user_id}: {resp_text}") |
|
return False |
|
|
|
except Exception as e: |
|
logger.error(f"❌ Error sending push notification to {target_user_id}: {e}") |
|
return False |
|
|
|
async def send_bulk_push_notifications(sender_id: str, target_user_ids: list): |
|
"""Envia notificações push para múltiplos usuários""" |
|
if not target_user_ids: |
|
logger.info("📭 No users to notify") |
|
return |
|
|
|
logger.info(f"📤 Sending push notifications to {len(target_user_ids)} users") |
|
|
|
|
|
tasks = [send_push_notification(sender_id, user_id) for user_id in target_user_ids] |
|
results = await asyncio.gather(*tasks, return_exceptions=True) |
|
|
|
success_count = sum(1 for result in results if result is True) |
|
logger.info(f"✅ Successfully sent {success_count}/{len(target_user_ids)} push notifications") |
|
|
|
|
|
|
|
def get_active_subscribers_by_price_id(price_id: str) -> list: |
|
""" |
|
Retorna uma lista de customer_ids que têm uma assinatura ativa com o price_id fornecido. |
|
""" |
|
response = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/Subscriptions?price_id=eq.{price_id}&active=eq.true&select=customer_id", |
|
headers=SUPABASE_ROLE_HEADERS |
|
) |
|
if response.status_code == 200: |
|
data = response.json() |
|
return [entry["customer_id"] for entry in data if "customer_id" in entry] |
|
logger.warning(f"⚠️ Failed to fetch subscribers: {response.status_code} - {response.text}") |
|
return [] |
|
|
|
def create_notifications_for_price_change(user_ids: list, stylist_id: str): |
|
""" |
|
Cria registros de notificações na tabela 'Notifications' para todos os usuários afetados. |
|
""" |
|
if not user_ids: |
|
return |
|
|
|
now = datetime.now(pytz.timezone("America/Sao_Paulo")).isoformat() |
|
payload = [{ |
|
"type": "changeprice", |
|
"target_user": uid, |
|
"created_at": now, |
|
"user_reference": stylist_id, |
|
"read": False, |
|
"post_reference": None |
|
} for uid in user_ids] |
|
|
|
response = requests.post( |
|
f"{SUPABASE_URL}/rest/v1/Notifications", |
|
headers=SUPABASE_ROLE_HEADERS, |
|
json=payload |
|
) |
|
|
|
if response.status_code not in [200, 201]: |
|
logger.warning(f"⚠️ Failed to create notifications: {response.status_code} - {response.text}") |
|
else: |
|
logger.info(f"✅ Created {len(payload)} notifications for affected users.") |
|
|
|
def get_subscription_from_db(subscription_id: str): |
|
""" |
|
Busca a assinatura na tabela 'Subscriptions' com base no subscription_id. |
|
""" |
|
response = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/Subscriptions?sub_id=eq.{subscription_id}", |
|
headers=SUPABASE_ROLE_HEADERS |
|
) |
|
|
|
if response.status_code == 200 and response.json(): |
|
return response.json()[0] |
|
return None |
|
|
|
def get_new_price_id(stylist_id: str): |
|
""" |
|
Busca o novo price_id da tabela 'User' com base no stylist_id. |
|
""" |
|
response = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/User?id=eq.{stylist_id}", |
|
headers=SUPABASE_ROLE_HEADERS |
|
) |
|
|
|
if response.status_code == 200 and response.json(): |
|
return response.json()[0].get("price_id") |
|
return None |
|
|
|
def update_subscription_price_in_db(subscription_id: str, new_price_id: str): |
|
""" |
|
Atualiza a coluna 'price_id' na tabela 'Subscriptions' para o novo price_id correspondente. |
|
""" |
|
update_data = {"price_id": new_price_id} |
|
|
|
response = requests.patch( |
|
f"{SUPABASE_URL}/rest/v1/Subscriptions?sub_id=eq.{subscription_id}", |
|
headers=SUPABASE_ROLE_HEADERS, |
|
json=update_data |
|
) |
|
|
|
return response.status_code in [200, 204] |
|
|
|
def verify_token(user_token: str) -> str: |
|
""" |
|
Valida o token JWT no Supabase e retorna o user_id se for válido. |
|
""" |
|
if not user_token or len(user_token.split('.')) != 3: |
|
raise HTTPException(status_code=400, detail="Invalid JWT token format") |
|
|
|
|
|
try: |
|
jwt.decode(user_token, options={"verify_signature": False}) |
|
except jwt.ExpiredSignatureError: |
|
raise HTTPException(status_code=401, detail="Token expired") |
|
except jwt.DecodeError: |
|
raise HTTPException(status_code=401, detail="Invalid JWT token") |
|
|
|
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.post("/update_subscription") |
|
async def update_subscription( |
|
data: UpdateSubscriptionRequest, |
|
user_token: str = Header(None, alias="User-key") |
|
): |
|
try: |
|
if not user_token: |
|
raise HTTPException(status_code=401, detail="Missing User-key header") |
|
|
|
|
|
user_id = verify_token(user_token) |
|
logger.info(f"🔹 User verified. user_id: {user_id}") |
|
|
|
subscription_id = data.subscription_id |
|
|
|
|
|
subscription_db = get_subscription_from_db(subscription_id) |
|
if not subscription_db: |
|
raise HTTPException(status_code=404, detail="Subscription not found in database.") |
|
|
|
stylist_id = subscription_db.get("stylist_id") |
|
customer_id = subscription_db.get("customer_id") |
|
|
|
|
|
if customer_id != user_id: |
|
raise HTTPException(status_code=403, detail="You are not authorized to modify this subscription.") |
|
|
|
|
|
new_price_id = get_new_price_id(stylist_id) |
|
if not new_price_id: |
|
raise HTTPException(status_code=404, detail="New price_id not found for stylist.") |
|
|
|
|
|
subscription = stripe.Subscription.retrieve(subscription_id) |
|
if not subscription: |
|
raise HTTPException(status_code=404, detail="Subscription not found in Stripe.") |
|
|
|
|
|
if subscription.status == "canceled" or subscription.cancel_at_period_end: |
|
stripe.Subscription.modify(subscription_id, cancel_at_period_end=False) |
|
logger.info(f"✅ Subscription {subscription_id} reactivated.") |
|
|
|
|
|
updated_subscription = stripe.Subscription.modify( |
|
subscription_id, |
|
items=[{"id": subscription["items"]["data"][0]["id"], "price": new_price_id}], |
|
proration_behavior="none" |
|
) |
|
|
|
logger.info(f"✅ Subscription {subscription_id} updated to new price {new_price_id}") |
|
|
|
|
|
if update_subscription_price_in_db(subscription_id, new_price_id): |
|
logger.info(f"✅ Subscription {subscription_id} price_id updated in database.") |
|
else: |
|
raise HTTPException(status_code=500, detail="Failed to update subscription price_id in database.") |
|
|
|
return { |
|
"message": "Subscription reactivated and updated successfully!", |
|
"subscription_id": subscription_id, |
|
"new_price_id": new_price_id |
|
} |
|
|
|
except stripe.error.StripeError as e: |
|
logger.error(f"❌ Stripe error: {e}") |
|
raise HTTPException(status_code=500, detail=f"Stripe error: {str(e)}") |
|
|
|
except Exception as e: |
|
logger.error(f"❌ Error updating subscription: {e}") |
|
raise HTTPException(status_code=500, detail=f"Error updating subscription: {str(e)}") |
|
|
|
@router.post("/sync_emergency_payments") |
|
async def sync_emergency_payments(request: Request, data: EmergencyPaymentRequest): |
|
try: |
|
|
|
user_token = request.headers.get("User-key") |
|
if not user_token: |
|
raise HTTPException(status_code=400, detail="Missing User-key header") |
|
|
|
|
|
user_id = verify_token(user_token) |
|
logger.info(f"User ID from token: {user_id}") |
|
|
|
|
|
response = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}", |
|
headers=SUPABASE_HEADERS |
|
) |
|
|
|
|
|
logger.info(f"Supabase response for user {user_id}: {response.status_code} {response.text}") |
|
|
|
if response.status_code != 200 or not response.json(): |
|
raise HTTPException(status_code=404, detail="User not found in database") |
|
|
|
stripe_id = response.json()[0].get("stripe_id") |
|
if not stripe_id: |
|
raise HTTPException(status_code=404, detail="Stripe ID not found for user") |
|
|
|
logger.info(f"Stripe ID for user {user_id}: {stripe_id}") |
|
|
|
|
|
stylist_id = data.id |
|
response_payments = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/Emergency_sub?client_id=eq.{user_id}&stylist_id=eq.{stylist_id}", |
|
headers=SUPABASE_HEADERS |
|
) |
|
|
|
|
|
logger.info(f"Supabase response for emergency payments: {response_payments.status_code} {response_payments.text}") |
|
|
|
if response_payments.status_code != 200: |
|
raise HTTPException(status_code=500, detail="Failed to fetch emergency payments") |
|
|
|
existing_payments = response_payments.json() |
|
logger.info(f"Found {len(existing_payments)} emergency payments for stylist {stylist_id} and customer {user_id}") |
|
|
|
|
|
stripe_payments = stripe.PaymentIntent.list( |
|
customer=stripe_id, |
|
expand=["data.charges"] |
|
) |
|
|
|
|
|
for stripe_payment in stripe_payments["data"]: |
|
payment_id = stripe_payment["id"] |
|
|
|
|
|
existing_payment = next((payment for payment in existing_payments if payment["payment_id"] == payment_id), None) |
|
|
|
if existing_payment: |
|
logger.info(f"Payment {payment_id} already exists in database, skipping.") |
|
continue |
|
|
|
|
|
price = int(stripe_payment["amount_received"]) |
|
subscription_id = stripe_payment["metadata"].get("subscription_id") |
|
payment_method = stripe_payment.get("payment_method") |
|
payment_type = "N/A" |
|
last4 = "N/A" |
|
if payment_method: |
|
payment_method_details = stripe.PaymentMethod.retrieve(payment_method) |
|
payment_type = payment_method_details["type"] |
|
last4 = payment_method_details[payment_type].get("last4", "N/A") |
|
|
|
emergency_sub_data = { |
|
"stylist_id": stylist_id, |
|
"client_id": user_id, |
|
"payment_id": payment_id, |
|
"price": price, |
|
"sub_id": subscription_id, |
|
} |
|
|
|
|
|
response_insert = requests.post( |
|
f"{SUPABASE_URL}/rest/v1/Emergency_sub", |
|
headers=SUPABASE_HEADERS, |
|
json=emergency_sub_data |
|
) |
|
|
|
if response_insert.status_code == 201: |
|
logger.info(f"Payment {payment_id} added successfully") |
|
else: |
|
logger.error(f"Failed to add payment {payment_id}: {response_insert.status_code} - {response_insert.text}") |
|
|
|
return {"status": "success", "message": "Emergency payments synchronized successfully"} |
|
|
|
except jwt.ExpiredSignatureError: |
|
raise HTTPException(status_code=401, detail="Token has expired") |
|
except jwt.InvalidTokenError: |
|
raise HTTPException(status_code=401, detail="Invalid token") |
|
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 in syncing emergency payments: {str(e)}") |
|
raise HTTPException(status_code=500, detail=f"Error in syncing emergency payments: {str(e)}") |
|
|
|
@router.post("/subscription_details") |
|
async def subscription_details(data: SubscriptionRequest): |
|
try: |
|
subscription_id = data.id |
|
|
|
|
|
subscription = await async_stripe_request( |
|
stripe.Subscription.retrieve, |
|
id=subscription_id, |
|
expand=["items.data.price", "default_payment_method", "customer"] |
|
) |
|
|
|
if not subscription: |
|
raise HTTPException(status_code=404, detail="Subscription not found") |
|
|
|
|
|
logger.info(f"Subscription retrieved: {subscription}") |
|
|
|
|
|
status = subscription["status"] |
|
if status == "active": |
|
subscription_status = "active" |
|
elif status == "canceled" and subscription["current_period_end"] > datetime.utcnow().timestamp(): |
|
subscription_status = "canceled" |
|
elif status == "canceled" and subscription["current_period_end"] <= datetime.utcnow().timestamp(): |
|
subscription_status = "canceled" |
|
else: |
|
subscription_status = "terminated" |
|
|
|
|
|
current_price = subscription["items"]["data"][0]["price"]["unit_amount"] / 100 |
|
currency = subscription["items"]["data"][0]["price"]["currency"].upper() |
|
|
|
|
|
logger.info(f"Tentando buscar preview invoice para subscription: {subscription_id}") |
|
try: |
|
logger.info("Chamando stripe.Invoice.create_preview via async_stripe_request...") |
|
|
|
|
|
customer_id = subscription.get("customer") |
|
if isinstance(customer_id, dict): |
|
customer_id = customer_id.get("id") |
|
|
|
|
|
preview_invoice = await async_stripe_request( |
|
stripe.Invoice.create_preview, |
|
customer=customer_id, |
|
subscription=subscription_id |
|
) |
|
|
|
logger.info(f"Preview invoice object type: {type(preview_invoice)}") |
|
logger.info(f"Preview invoice retrieved successfully") |
|
logger.info(f"Amount due: {preview_invoice.get('amount_due') if preview_invoice else 'None'}") |
|
logger.info(f"Currency: {preview_invoice.get('currency') if preview_invoice else 'None'}") |
|
logger.info(f"Status: {preview_invoice.get('status', 'No status') if preview_invoice else 'None'}") |
|
|
|
next_invoice_amount = preview_invoice.get('amount_due') / 100 if preview_invoice and preview_invoice.get('amount_due') else None |
|
logger.info(f"Calculated next_invoice_amount: {next_invoice_amount}") |
|
|
|
except stripe.error.StripeError as e: |
|
logger.error(f"Stripe error getting preview invoice: {str(e)}") |
|
logger.error(f"Stripe error type: {type(e)}") |
|
next_invoice_amount = None |
|
except Exception as e: |
|
logger.error(f"General error getting preview invoice: {str(e)}") |
|
logger.error(f"Error type: {type(e)}") |
|
next_invoice_amount = None |
|
|
|
if next_invoice_amount is None: |
|
next_invoice_amount = "N/A" |
|
|
|
|
|
payment_method = subscription.get("default_payment_method") |
|
if payment_method: |
|
payment_method_details = await async_stripe_request( |
|
stripe.PaymentMethod.retrieve, |
|
id=payment_method |
|
) |
|
payment_type = payment_method_details["type"] |
|
last4 = payment_method_details[payment_type].get("last4", "N/A") |
|
payment_info = f"{payment_type.upper()} ending in {last4}" |
|
logger.info(f"Payment method details: {payment_method_details}") |
|
else: |
|
payment_info = "No default payment method found" |
|
|
|
|
|
stylist_id = subscription["metadata"].get("stylist_id", "N/A") |
|
|
|
|
|
stylist_avatar = None |
|
stylist_name = None |
|
if stylist_id != "N/A": |
|
response = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/User?id=eq.{stylist_id}&select=avatar,name", |
|
headers=SUPABASE_HEADERS |
|
) |
|
if response.status_code == 200: |
|
data = response.json() |
|
if data: |
|
stylist_avatar = data[0].get("avatar") |
|
stylist_name = data[0].get("name") |
|
logger.info(f"Stylist data retrieved: {data}") |
|
|
|
|
|
emergency_subscriptions = [] |
|
response = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/Emergency_sub?sub_id=eq.{subscription_id}", |
|
headers=SUPABASE_HEADERS |
|
) |
|
if response.status_code == 200: |
|
emergency_subscriptions = response.json() |
|
logger.info(f"Emergency subscriptions retrieved: {emergency_subscriptions}") |
|
|
|
|
|
customer_email = "N/A" |
|
customer_id = subscription.get("customer") |
|
|
|
|
|
if isinstance(customer_id, dict): |
|
customer_id = customer_id.get("id") |
|
|
|
if customer_id and isinstance(customer_id, str): |
|
customer_details = await async_stripe_request( |
|
stripe.Customer.retrieve, |
|
id=customer_id |
|
) |
|
customer_email = customer_details.get("email", "N/A") |
|
logger.info(f"Customer details retrieved: {customer_details}") |
|
|
|
|
|
result = { |
|
"status": subscription_status, |
|
"subscription_id": subscription_id, |
|
"current_price": f"{current_price} {currency}", |
|
"next_invoice_amount": f"{next_invoice_amount} {currency}" if next_invoice_amount != "N/A" else "N/A", |
|
"next_billing_date": datetime.utcfromtimestamp(subscription["current_period_end"]).isoformat(), |
|
"payment_method": payment_info, |
|
"stylist_id": stylist_id, |
|
"stylist_avatar": stylist_avatar, |
|
"stylist_name": stylist_name, |
|
"emergency_subscriptions": emergency_subscriptions, |
|
"customer_email": customer_email |
|
} |
|
|
|
logger.info(f"Final result being returned: {result}") |
|
return result |
|
|
|
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 retrieving subscription details: {str(e)}") |
|
raise HTTPException(status_code=500, detail="Error retrieving subscription details.") |
|
|
|
@router.post("/generate_dashboard_link") |
|
async def generate_dashboard_link(data: UserIDRequest): |
|
try: |
|
user_id = data.user_id |
|
|
|
|
|
if user_id.startswith("acct_"): |
|
try: |
|
|
|
login_link = stripe.Account.create_login_link(user_id) |
|
|
|
return { |
|
"status": "success", |
|
"dashboard_link": login_link.url |
|
} |
|
except stripe.error.StripeError as e: |
|
logger.error(f"Error creating login link for stylist account: {str(e)}") |
|
raise HTTPException(status_code=500, detail="Error creating login link for stylist account.") |
|
|
|
|
|
elif user_id.startswith("cus_"): |
|
try: |
|
|
|
session = stripe.billing_portal.Session.create( |
|
customer=user_id, |
|
return_url="https://your-website.com/account" |
|
) |
|
|
|
return { |
|
"status": "success", |
|
"dashboard_link": session.url |
|
} |
|
|
|
except stripe.error.StripeError as e: |
|
logger.error(f"Error creating billing portal session for customer: {str(e)}") |
|
raise HTTPException(status_code=500, detail="Error creating billing portal session for customer.") |
|
|
|
else: |
|
raise HTTPException(status_code=400, detail="Invalid user ID format. Must start with 'acct_' for stylist or 'cus_' for customer.") |
|
|
|
except Exception as e: |
|
logger.error(f"Error generating dashboard link: {str(e)}") |
|
raise HTTPException(status_code=500, detail="Error generating dashboard link.") |
|
|
|
@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", {}) |
|
|
|
amount_paid = invoice.get("amount_paid", 0) |
|
currency = invoice.get("currency", "usd") |
|
|
|
|
|
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 {} |
|
|
|
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") |
|
|
|
|
|
consultations = ( |
|
int(metadata.get("consultations", 0)) or |
|
int(subscription_metadata.get("consultations", 0)) or |
|
int(line_item_metadata.get("consultations", 0)) |
|
) |
|
|
|
stylist_amount = int(amount_paid * 0.8) |
|
platform_amount = int(amount_paid * 0.2) |
|
|
|
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"📌 Número de consultas: {consultations}") |
|
|
|
|
|
subscription_data = { |
|
"stylist_id": stylist_id, |
|
"customer_id": user_id, |
|
"active": True, |
|
"sub_id": subscription_id, |
|
"price_id": price_id, |
|
"consultations": consultations |
|
} |
|
|
|
logger.info(f"📝 Enviando dados da assinatura ao Supabase: {subscription_data}") |
|
|
|
response_subscription = requests.post( |
|
f"{SUPABASE_URL}/rest/v1/Subscriptions", |
|
headers=SUPABASE_HEADERS, |
|
json=subscription_data |
|
) |
|
|
|
if response_subscription.status_code == 201: |
|
logger.info(f"✅ Subscription adicionada com sucesso para o usuário {user_id}") |
|
else: |
|
logger.error(f"❌ Falha ao adicionar subscription: {response_subscription.status_code} - {response_subscription.text}") |
|
|
|
|
|
check_chat_url = f"{SUPABASE_URL}/rest/v1/chats?stylist_id=eq.{stylist_id}&client_id=eq.{user_id}" |
|
response_check_chat = requests.get( |
|
check_chat_url, |
|
headers=SUPABASE_ROLE_HEADERS |
|
) |
|
|
|
chat_id = None |
|
if response_check_chat.status_code == 200: |
|
existing_chats = response_check_chat.json() |
|
if existing_chats: |
|
chat_id = existing_chats[0]['id'] |
|
logger.info(f"ℹ️ Chat já existe para stylist {stylist_id} e cliente {user_id}") |
|
else: |
|
chat_data = {"stylist_id": stylist_id, "client_id": user_id} |
|
create_chat_headers = SUPABASE_ROLE_HEADERS.copy() |
|
create_chat_headers["Prefer"] = "return=representation" |
|
response_chat = requests.post( |
|
f"{SUPABASE_URL}/rest/v1/chats", |
|
headers=create_chat_headers, |
|
json=chat_data |
|
) |
|
if response_chat.status_code == 201: |
|
new_chat_data = response_chat.json() |
|
chat_id = new_chat_data[0]['id'] if isinstance(new_chat_data, list) else new_chat_data.get('id') |
|
logger.info(f"✅ Chat criado para stylist {stylist_id} e cliente {user_id}") |
|
else: |
|
logger.error(f"❌ Falha ao criar chat: {response_chat.status_code} - {response_chat.text}") |
|
|
|
|
|
if chat_id: |
|
user_response = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}&select=name", |
|
headers=SUPABASE_ROLE_HEADERS |
|
) |
|
|
|
formatted_name = "User" |
|
if user_response.status_code == 200: |
|
user_data = user_response.json() |
|
if user_data: |
|
full_name = user_data[0].get('name', '').strip() |
|
name_parts = full_name.split() |
|
formatted_name = f"{name_parts[0]} {name_parts[1][0]}" if len(name_parts) > 1 else name_parts[0] |
|
|
|
message_data = { |
|
"chat_id": chat_id, |
|
"sender_id": user_id, |
|
"content": f"{formatted_name} subscribed for 1 month", |
|
"type": "warning" |
|
} |
|
|
|
create_message_headers = SUPABASE_ROLE_HEADERS.copy() |
|
create_message_headers["Prefer"] = "return=representation" |
|
response_message = requests.post( |
|
f"{SUPABASE_URL}/rest/v1/messages", |
|
headers=create_message_headers, |
|
json=message_data |
|
) |
|
|
|
if response_message.status_code == 201: |
|
logger.info(f"✅ Mensagem inicial criada para chat {chat_id}") |
|
else: |
|
logger.error(f"❌ Falha ao criar mensagem inicial: {response_message.status_code} - {response_message.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...") |
|
|
|
|
|
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."} |
|
|
|
elif event_type == "customer.subscription.updated": |
|
subscription = payload.get("data", {}).get("object", {}) |
|
subscription_id = subscription.get("id") |
|
canceled_at = subscription.get("canceled_at") |
|
cancel_status = subscription.get("cancel_at_period_end", False) |
|
|
|
|
|
supabase_headers = { |
|
"Authorization": f"Bearer {SUPABASE_KEY}", |
|
"apikey": SUPABASE_KEY, |
|
"Content-Type": "application/json" |
|
} |
|
|
|
|
|
if canceled_at: |
|
|
|
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).") |
|
|
|
|
|
update_url = f"{SUPABASE_URL}/rest/v1/Subscriptions?sub_id=eq.{subscription_id}" |
|
update_data = { |
|
"canceled": True, |
|
"canceled_date": canceled_date |
|
} |
|
|
|
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."} |
|
|
|
|
|
elif not cancel_status: |
|
logger.info(f"🔹 Subscription {subscription_id} reactivated.") |
|
|
|
|
|
update_url = f"{SUPABASE_URL}/rest/v1/Subscriptions?sub_id=eq.{subscription_id}" |
|
update_data = { |
|
"canceled": False, |
|
"canceled_date": None |
|
} |
|
|
|
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} reactivated and canceled data removed.") |
|
return {"status": "success", "message": "Subscription reactivated and canceled date removed."} |
|
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 or reactivated.") |
|
return {"status": "success", "message": "Subscription updated but not canceled or reactivated."} |
|
|
|
elif event_type == "payment_intent.succeeded": |
|
payment_intent = payload.get("data", {}).get("object", {}) |
|
|
|
|
|
stylist_id = payment_intent.get("metadata", {}).get("stylist_id") |
|
client_id = payment_intent.get("metadata", {}).get("user_id") |
|
invoice_id = payment_intent.get("invoice") |
|
payment_id = payment_intent.get("id") |
|
amount_received = payment_intent.get("amount_received", 0) |
|
stylist_stripe_id = payment_intent.get("metadata", {}).get("stylist_stripe_id") |
|
|
|
if not all([stylist_id, client_id, payment_id]): |
|
logger.error("❌ Faltando dados essenciais no metadado do Payment Intent.") |
|
return {"status": "error", "message": "Missing essential metadata."} |
|
|
|
|
|
subscription_id = None |
|
if invoice_id: |
|
try: |
|
invoice = stripe.Invoice.retrieve(invoice_id) |
|
subscription_id = invoice.get("subscription") |
|
except Exception as e: |
|
logger.error(f"❌ Erro ao obter subscription_id da fatura: {str(e)}") |
|
|
|
logger.info(f"🔹 Payment Intent succeeded for subscription {subscription_id}") |
|
logger.info(f"👤 Client ID: {client_id}") |
|
logger.info(f"👗 Stylist ID: {stylist_id}") |
|
logger.info(f"💰 Price: R$ {amount_received / 100:.2f}") |
|
logger.info(f"💳 Payment ID: {payment_id}") |
|
|
|
|
|
if stylist_stripe_id and amount_received > 0: |
|
try: |
|
|
|
stylist_amount = int(amount_received * 0.8) |
|
|
|
|
|
transfer = stripe.Transfer.create( |
|
amount=stylist_amount, |
|
currency="brl", |
|
destination=stylist_stripe_id, |
|
source_transaction=payment_intent.get("latest_charge"), |
|
description=f"Pagamento de assinatura para {stylist_id}", |
|
metadata={ |
|
"stylist_id": stylist_id, |
|
"client_id": client_id, |
|
"payment_intent_id": payment_id, |
|
"subscription_id": subscription_id |
|
} |
|
) |
|
logger.info(f"✅ Transferência criada com sucesso: {transfer.id} - Valor: R$ {stylist_amount / 100:.2f}") |
|
except Exception as e: |
|
logger.error(f"❌ Erro ao criar transferência: {str(e)}") |
|
|
|
|
|
supabase_headers = { |
|
"Authorization": f"Bearer {SUPABASE_KEY}", |
|
"apikey": SUPABASE_KEY, |
|
"Content-Type": "application/json" |
|
} |
|
|
|
|
|
check_url = f"{SUPABASE_URL}/rest/v1/Emergency_sub?payment_id=eq.{payment_id}" |
|
response_check = requests.get(check_url, headers=supabase_headers) |
|
|
|
if response_check.status_code == 200 and len(response_check.json()) > 0: |
|
logger.info(f"❌ Payment ID {payment_id} já existe. Nenhuma nova linha será inserida.") |
|
return {"status": "error", "message": "Payment ID already exists."} |
|
|
|
|
|
emergency_sub_data = { |
|
"sub_id": subscription_id, |
|
"stylist_id": stylist_id, |
|
"client_id": client_id, |
|
"price": amount_received, |
|
"payment_id": payment_id |
|
} |
|
|
|
emergency_sub_url = f"{SUPABASE_URL}/rest/v1/Emergency_sub" |
|
response_emergency_sub = requests.post( |
|
emergency_sub_url, |
|
headers=supabase_headers, |
|
json=emergency_sub_data |
|
) |
|
|
|
if response_emergency_sub.status_code == 201: |
|
logger.info(f"✅ Emergency subscription data added successfully for client {client_id}") |
|
else: |
|
logger.error(f"❌ Failed to add emergency subscription: {response_emergency_sub.status_code} - {response_emergency_sub.text}") |
|
|
|
return { |
|
"status": "success", |
|
"message": "Emergency subscription added successfully" |
|
} |
|
|
|
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") |
|
|
|
|
|
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 or 0 |
|
|
|
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") |
|
if consultations < 0: |
|
raise HTTPException(status_code=400, detail="Consultations must be >= 0") |
|
|
|
logger.info(f"🔹 Validated inputs") |
|
|
|
|
|
supabase_url = f"{SUPABASE_URL}/rest/v1/User?id=eq.{user_id}" |
|
headers = { |
|
"Authorization": f"Bearer {user_token}", |
|
**SUPABASE_HEADERS |
|
} |
|
response = requests.get(supabase_url, headers=headers) |
|
|
|
if response.status_code != 200: |
|
raise HTTPException(status_code=500, detail=f"Failed to fetch user: {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_full_name = user.get("name", "Unknown") |
|
user_avatar = user.get("avatar") |
|
|
|
name_parts = user_full_name.split() |
|
abbreviated_name = f"{name_parts[0]} {name_parts[1][0]}." if len(name_parts) > 1 else name_parts[0] |
|
product_name = f"{abbreviated_name} Subscription" |
|
|
|
logger.info(f"🔹 Creating Stripe product") |
|
|
|
product_data = {"name": product_name} |
|
if user_avatar: |
|
product_data["images"] = [user_avatar] |
|
|
|
product = stripe.Product.create(**product_data) |
|
product_id = product.id |
|
|
|
price = stripe.Price.create( |
|
unit_amount=amount, |
|
currency="brl", |
|
recurring={"interval": "month"}, |
|
product=product_id |
|
) |
|
new_price_id = price.id |
|
logger.info(f"✅ Stripe product and price created: {new_price_id}") |
|
|
|
affected_users = [] |
|
|
|
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) |
|
stripe.Subscription.modify( |
|
sub.id, |
|
items=[{ |
|
"id": sub["items"]["data"][0]["id"], |
|
"price": new_price_id |
|
}], |
|
proration_behavior="none" |
|
) |
|
|
|
affected_users = get_active_subscribers_by_price_id(existing_price_id) |
|
logger.info(f"🔹 Found {len(affected_users)} affected users.") |
|
|
|
|
|
update_data = { |
|
"price_id": new_price_id, |
|
"price": amount, |
|
"emergency_price": emergency_price, |
|
"consultations": consultations |
|
} |
|
|
|
update_headers = { |
|
"Authorization": f"Bearer {user_token}", |
|
"apikey": SUPABASE_KEY, |
|
"Content-Type": "application/json" |
|
} |
|
|
|
update_response = requests.patch(supabase_url, headers=update_headers, json=update_data) |
|
|
|
if update_response.status_code not in [200, 204]: |
|
raise HTTPException(status_code=500, detail=f"Failed to update user: {update_response.text}") |
|
|
|
|
|
create_notifications_for_price_change(affected_users, stylist_id=user_id) |
|
|
|
|
|
if affected_users: |
|
try: |
|
await send_bulk_push_notifications(sender_id=user_id, target_user_ids=affected_users) |
|
logger.info(f"🔔 Push notifications process completed for {len(affected_users)} users") |
|
except Exception as push_error: |
|
logger.error(f"⚠️ Error sending push notifications: {push_error}") |
|
|
|
|
|
logger.info(f"✅ User updated, notifications created and push notifications sent") |
|
|
|
return { |
|
"message": "Price created and user updated successfully!", |
|
"price_id": new_price_id, |
|
"affected_users_count": len(affected_users), |
|
"notifications_sent": True |
|
} |
|
|
|
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("/emergency_checkout_session") |
|
def emergency_checkout_session( |
|
data: EmergencyPaymentRequest, |
|
user_token: str = Header(None, alias="User-key") |
|
): |
|
try: |
|
if not user_token: |
|
raise HTTPException(status_code=401, detail="Missing User-key header") |
|
|
|
|
|
user_id = verify_token(user_token) |
|
|
|
|
|
response_subscription = requests.get( |
|
f"{SUPABASE_URL}/rest/v1/Subscriptions?customer_id=eq.{user_id}&stylist_id=eq.{data.id}&active=eq.true", |
|
headers=SUPABASE_HEADERS |
|
) |
|
subscription_data = response_subscription.json() |
|
if not subscription_data: |
|
raise HTTPException(status_code=403, detail="User is not eligible for emergency checkout") |
|
|
|
|
|
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") |
|
stylist_stripe_id = stylist.get("stripe_id") |
|
emergency_price = stylist.get("emergency_price") |
|
|
|
if not stylist_stripe_id or emergency_price is None: |
|
raise HTTPException(status_code=400, detail="Stylist profile is incomplete") |
|
|
|
|
|
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") |
|
|
|
if not user_stripe_id: |
|
raise HTTPException(status_code=400, detail="Client does not have a Stripe Customer ID") |
|
|
|
|
|
payment_intent = stripe.PaymentIntent.create( |
|
amount=emergency_price, |
|
currency="brl", |
|
customer=user_stripe_id, |
|
application_fee_amount=int(emergency_price * 0.20), |
|
transfer_data={ |
|
"destination": stylist_stripe_id, |
|
"amount": int(price_amount * 0.8 * 100) |
|
}, |
|
metadata={ |
|
"stylist_id": stylist_id, |
|
"client_id": user_id, |
|
"subscription_id": subscription_data[0]["sub_id"] |
|
} |
|
) |
|
|
|
return { |
|
"message": "Emergency checkout session created successfully!", |
|
"clientSecret": payment_intent.client_secret, |
|
"paymentIntentId": payment_intent.id, |
|
"price": f"{emergency_price / 100:.2f} USD", |
|
"stylist_id": stylist_id, |
|
"client_id": user_id, |
|
"subscription_id": subscription_data[0]["sub_id"] |
|
} |
|
|
|
except Exception as e: |
|
logger.error(f"Error creating emergency checkout session: {e}") |
|
raise HTTPException(status_code=500, detail=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") |
|
|
|
user_id = verify_token(user_token) |
|
|
|
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") |
|
stylist_stripe_id = stylist.get("stripe_id") |
|
price_id = stylist.get("price_id") |
|
consultations = stylist.get("consultations", 0) |
|
|
|
if not stylist_stripe_id or not price_id: |
|
raise HTTPException(status_code=400, detail="Stylist profile is incomplete") |
|
|
|
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") |
|
|
|
if not user_stripe_id: |
|
raise HTTPException(status_code=400, detail="Client does not have a Stripe Customer ID") |
|
|
|
price_data = stripe.Price.retrieve(price_id) |
|
price_amount = price_data.unit_amount / 100 |
|
currency = price_data.currency.upper() |
|
|
|
subscription = stripe.Subscription.create( |
|
customer=user_stripe_id, |
|
items=[{"price": price_id}], |
|
payment_behavior='default_incomplete', |
|
expand=['latest_invoice.payment_intent'], |
|
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, |
|
"consultations": consultations |
|
} |
|
) |
|
|
|
payment_intent = subscription.latest_invoice.payment_intent |
|
stripe.PaymentIntent.modify( |
|
payment_intent.id, |
|
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, |
|
"consultations": consultations |
|
} |
|
) |
|
|
|
return { |
|
"message": "Subscription session created successfully!", |
|
"clientSecret": subscription.latest_invoice.payment_intent.client_secret, |
|
"subscriptionId": subscription.id, |
|
"price": f"{price_amount:.2f} {currency}" |
|
} |
|
|
|
except Exception as e: |
|
logger.error(f"Error creating subscription session: {e}") |
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
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)) |
|
|
|
class ReactivateSubscriptionRequest(BaseModel): |
|
subscription_id: str |
|
|
|
@router.post("/reactivate_subscription") |
|
def reactivate_subscription(data: ReactivateSubscriptionRequest): |
|
try: |
|
subscription = stripe.Subscription.modify( |
|
data.subscription_id, |
|
cancel_at_period_end=False, |
|
) |
|
return {"status": "Subscription has been reactivated"} |
|
except Exception as e: |
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
@router.post("/check_subscription") |
|
async def check_subscription( |
|
data: CheckSubscriptionRequest |
|
): |
|
try: |
|
user_id = data.user_id |
|
|
|
|
|
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") |
|
|
|
|
|
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"]: |
|
|
|
logger.info(f"No active subscription found for user {user_id}. Deactivating all subscriptions for stylist {data.stylist_id}.") |
|
|
|
|
|
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."} |
|
|
|
|
|
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") |
|
|
|
|
|
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 |
|
) |
|
|
|
|
|
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 |
|
} |
|
|
|
|
|
response_existing_sub = await async_request( |
|
f"{SUPABASE_URL}/rest/v1/Subscriptions?customer_id=eq.{user_id}&stylist_id=eq.{data.stylist_id}&sub_id=eq.{subscription_id}", |
|
SUPABASE_HEADERS |
|
) |
|
if response_existing_sub.status_code == 200 and response_existing_sub.json(): |
|
db_subscription = response_existing_sub.json()[0] |
|
db_active = db_subscription.get("active") |
|
|
|
|
|
if not db_active and status == "active": |
|
await update_subscription_status(db_subscription["id"], True) |
|
|
|
|
|
if response_existing_sub.status_code != 200 or not response_existing_sub.json(): |
|
await async_request( |
|
f"{SUPABASE_URL}/rest/v1/Subscriptions", |
|
SUPABASE_HEADERS, |
|
json=subscription_data, |
|
method='POST' |
|
) |
|
|
|
|
|
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 |
|
} |
|
|
|
|
|
if not stylist_found: |
|
logger.info(f"No active subscription found for stylist {data.stylist_id}. Deactivating all subscriptions for this stylist.") |
|
|
|
|
|
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.") |
|
|
|
|
|
|
|
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 |
|
|
|
|
|
async def async_stripe_request(func, **kwargs): |
|
loop = asyncio.get_event_loop() |
|
response = await loop.run_in_executor(None, lambda: func(**kwargs)) |
|
return response |
|
|
|
|
|
async def update_subscription_status(subscription_id, status): |
|
update_data = { |
|
"active": 1 if status else 0 |
|
} |
|
subscription_url = f"{SUPABASE_URL}/rest/v1/Subscriptions?id=eq.{subscription_id}" |
|
|
|
logger.info(f"Request to update subscription {subscription_id} with data: {update_data}") |
|
|
|
|
|
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} updated to {'active' if status else 'inactive'} successfully.") |
|
|
|
updated_data = update_response.json() |
|
logger.info(f"Updated subscription data: {updated_data}") |
|
else: |
|
logger.error(f"❌ Failed to update subscription {subscription_id}. Response status: {update_response.status_code}. Response: {update_response.text}") |