mirror of
https://github.com/omnara-ai/omnara.git
synced 2025-08-12 20:39:09 +03:00
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""Mobile billing endpoints for iOS and Android subscriptions."""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
import httpx
|
|
|
|
from fastapi import APIRouter, Request, HTTPException, Depends
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.auth.dependencies import get_current_user
|
|
from backend.models import SubscriptionResponse
|
|
from shared.config import settings
|
|
from shared.database.models import User
|
|
from shared.database.subscription_models import BillingEvent
|
|
from shared.database.session import get_db
|
|
from shared.database.billing_operations import (
|
|
get_or_create_subscription,
|
|
create_billing_event,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/billing/mobile", tags=["mobile-billing"])
|
|
|
|
# RevenueCat API configuration
|
|
REVENUECAT_API_URL = "https://api.revenuecat.com/v1"
|
|
REVENUECAT_API_KEY = settings.revenuecat_secret_key
|
|
|
|
|
|
@router.get("/status", response_model=SubscriptionResponse)
|
|
async def get_mobile_subscription_status(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get current subscription status.
|
|
|
|
Mobile apps should poll this endpoint after initiating a purchase
|
|
to check when the webhook has processed the subscription.
|
|
"""
|
|
subscription = get_or_create_subscription(current_user.id, db)
|
|
|
|
return SubscriptionResponse(
|
|
id=subscription.id,
|
|
plan_type=subscription.plan_type,
|
|
agent_limit=subscription.agent_limit,
|
|
current_period_end=None,
|
|
cancel_at_period_end=False,
|
|
provider=subscription.provider,
|
|
)
|
|
|
|
|
|
async def fetch_subscriber_from_revenuecat(app_user_id: str) -> Optional[dict]:
|
|
"""Fetch current subscriber status from RevenueCat API."""
|
|
if not REVENUECAT_API_KEY:
|
|
logger.error("RevenueCat API key not configured")
|
|
return None
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {REVENUECAT_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{REVENUECAT_API_URL}/subscribers/{app_user_id}",
|
|
headers=headers,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 403:
|
|
logger.error(
|
|
f"403 Forbidden when fetching subscriber {app_user_id}. "
|
|
f"Check that REVENUECAT_SECRET_KEY is set correctly and "
|
|
f"is for the same project as the webhook."
|
|
)
|
|
elif e.response.status_code == 404:
|
|
logger.warning(f"Subscriber {app_user_id} not found in RevenueCat")
|
|
else:
|
|
logger.error(f"Failed to fetch subscriber {app_user_id}: {e}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error fetching subscriber {app_user_id}: {e}")
|
|
return None
|
|
|
|
|
|
def sync_subscription_status(subscriber_data: dict, db: Session) -> bool:
|
|
"""
|
|
Sync RevenueCat subscriber data to our database.
|
|
|
|
Since we're using Purchases.logIn() with user IDs, the app_user_id
|
|
should match our user IDs directly.
|
|
"""
|
|
if not subscriber_data:
|
|
return False
|
|
|
|
try:
|
|
# Log the top-level keys to debug structure
|
|
logger.info(
|
|
f"Top-level RevenueCat response keys: {list(subscriber_data.keys())}"
|
|
)
|
|
|
|
subscriber = subscriber_data.get("subscriber", {})
|
|
app_user_id = subscriber.get("original_app_user_id")
|
|
|
|
logger.info(
|
|
f"RevenueCat subscriber data keys: {list(subscriber.keys()) if subscriber else 'None'}"
|
|
)
|
|
logger.info(
|
|
f"Entitlements type: {type(subscriber.get('entitlements'))}, value: {subscriber.get('entitlements')}"
|
|
)
|
|
|
|
if not app_user_id:
|
|
return False
|
|
|
|
try:
|
|
user_uuid = UUID(app_user_id)
|
|
except ValueError:
|
|
logger.error(f"Invalid user ID format from RevenueCat: {app_user_id}")
|
|
return False
|
|
|
|
# Get subscription directly by user ID
|
|
subscription = get_or_create_subscription(user_uuid, db)
|
|
logger.info(
|
|
f"Current subscription state: plan_type={subscription.plan_type}, user_id={subscription.user_id}"
|
|
)
|
|
|
|
# Check if user has the "Pro" entitlement
|
|
# IMPORTANT: RevenueCat returns ALL entitlements including expired ones
|
|
# We must check the expiration date to determine if it's currently active
|
|
entitlements = subscriber.get("entitlements", {})
|
|
has_pro_entitlement = False
|
|
|
|
if "Pro" in entitlements:
|
|
pro_entitlement = entitlements["Pro"]
|
|
expires_date_str = pro_entitlement.get("expires_date")
|
|
|
|
if expires_date_str:
|
|
try:
|
|
# Parse the ISO format date from RevenueCat
|
|
# RevenueCat uses ISO 8601 with Z suffix for UTC
|
|
expires_date = datetime.fromisoformat(
|
|
expires_date_str.replace("Z", "+00:00")
|
|
)
|
|
current_time = datetime.now(timezone.utc)
|
|
has_pro_entitlement = current_time < expires_date
|
|
|
|
logger.info(
|
|
f"Pro entitlement expires at {expires_date.isoformat()}, "
|
|
f"current time {current_time.isoformat()}, active={has_pro_entitlement}"
|
|
)
|
|
except ValueError as e:
|
|
logger.error(
|
|
f"Failed to parse expiration date '{expires_date_str}': {e}"
|
|
)
|
|
# If we can't parse the date, assume it's active to avoid accidental downgrades
|
|
has_pro_entitlement = True
|
|
else:
|
|
# No expiration date could mean lifetime subscription
|
|
# But for our use case (monthly subscriptions), this shouldn't happen
|
|
logger.warning("Pro entitlement found without expiration date")
|
|
has_pro_entitlement = True
|
|
|
|
logger.info(
|
|
f"RevenueCat entitlements: {list(entitlements.keys()) if entitlements else 'None'}, has_pro={has_pro_entitlement}"
|
|
)
|
|
|
|
# Check for active subscriptions
|
|
# RevenueCat returns all subscriptions, we need to find active ones
|
|
subscriptions = subscriber.get("subscriptions", {})
|
|
active_subscription_id = None
|
|
active_store = None
|
|
|
|
# Find subscription that hasn't expired and wasn't cancelled
|
|
for sub_id, sub_data in subscriptions.items():
|
|
expires_date = sub_data.get("expires_date")
|
|
if expires_date and not sub_data.get("unsubscribe_detected_at"):
|
|
# Simple check: if expires_date exists and no cancellation, consider it active
|
|
# RevenueCat handles grace periods and other edge cases
|
|
active_subscription_id = sub_data.get("store_transaction_id")
|
|
active_store = sub_data.get("store", "app_store")
|
|
break
|
|
|
|
# Update subscription status based on the "pro" entitlement
|
|
if has_pro_entitlement:
|
|
# Determine provider from the active subscription's store
|
|
if active_store:
|
|
provider = "apple" if active_store == "app_store" else "google"
|
|
else:
|
|
# Fallback - check management URL
|
|
management_url = subscriber.get("management_url")
|
|
if management_url and "apple.com" in management_url:
|
|
provider = "apple"
|
|
elif management_url and "play.google" in management_url:
|
|
provider = "google"
|
|
else:
|
|
# Keep existing provider if we can't determine it
|
|
provider = subscription.provider
|
|
|
|
subscription.plan_type = "pro"
|
|
subscription.agent_limit = -1
|
|
|
|
# Update customer ID if switching providers or if it's not set
|
|
if (
|
|
not subscription.provider_customer_id
|
|
or subscription.provider != provider
|
|
):
|
|
subscription.provider_customer_id = app_user_id
|
|
|
|
subscription.provider = provider
|
|
|
|
# Update provider subscription ID if available
|
|
if active_subscription_id:
|
|
subscription.provider_subscription_id = str(active_subscription_id)
|
|
else:
|
|
# No active subscription - revert to free
|
|
logger.info("No active Pro entitlement found, downgrading to free")
|
|
subscription.plan_type = "free"
|
|
subscription.agent_limit = 10
|
|
subscription.provider = None # Clear provider when going to free
|
|
subscription.provider_subscription_id = None # Clear subscription reference
|
|
|
|
db.commit()
|
|
|
|
logger.info(
|
|
f"Synced subscription for user {subscription.user_id}: "
|
|
f"plan={subscription.plan_type}"
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to sync subscription status: {e}", exc_info=True)
|
|
logger.error(f"Subscriber data: {subscriber_data}")
|
|
db.rollback()
|
|
return False
|
|
|
|
|
|
@router.post("/revenuecat/webhook")
|
|
async def handle_revenuecat_webhook(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Handle RevenueCat webhook events.
|
|
|
|
Following RevenueCat's recommendation, we use webhooks as triggers
|
|
to fetch the current subscription status rather than parsing each
|
|
event type individually.
|
|
"""
|
|
try:
|
|
# Verify webhook authorization if configured
|
|
if settings.revenuecat_webhook_auth_header:
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header != settings.revenuecat_webhook_auth_header:
|
|
logger.warning(
|
|
f"Invalid webhook authorization header. "
|
|
f"Received: '{auth_header}', Expected: '{settings.revenuecat_webhook_auth_header}'"
|
|
)
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
# Get webhook payload
|
|
body = await request.body()
|
|
payload = json.loads(body)
|
|
|
|
# Extract event details from RevenueCat webhook structure
|
|
event = payload.get("event", {})
|
|
event_type = event.get("type", "unknown")
|
|
event_id = event.get("id")
|
|
app_user_id = event.get("app_user_id")
|
|
original_app_user_id = event.get("original_app_user_id")
|
|
|
|
logger.info(
|
|
f"Received RevenueCat webhook: {event_type} (id: {event_id}) for user {app_user_id or original_app_user_id}"
|
|
)
|
|
|
|
# Check if we've already processed this webhook event
|
|
if event_id:
|
|
existing_event = (
|
|
db.query(BillingEvent)
|
|
.filter(BillingEvent.provider_event_id == event_id)
|
|
.first()
|
|
)
|
|
|
|
if existing_event:
|
|
logger.info(
|
|
f"Already processed webhook event {event_id}, returning cached result"
|
|
)
|
|
return {"status": "already_processed", "event_id": event_id}
|
|
|
|
# Use app_user_id (current) with fallback to original_app_user_id
|
|
user_id_to_use = app_user_id or original_app_user_id
|
|
if not user_id_to_use:
|
|
return {"status": "ignored", "reason": "no app_user_id"}
|
|
|
|
# For certain events, we should process immediately
|
|
important_events = [
|
|
"INITIAL_PURCHASE",
|
|
"RENEWAL",
|
|
"CANCELLATION",
|
|
"UNCANCELLATION",
|
|
"EXPIRATION",
|
|
"BILLING_ISSUE",
|
|
"PRODUCT_CHANGE",
|
|
"TEST", # RevenueCat test events
|
|
]
|
|
|
|
# Only process important events
|
|
if event_type not in important_events:
|
|
logger.info(f"Ignoring non-critical event: {event_type}")
|
|
return {"status": "ignored", "reason": "non-critical event"}
|
|
|
|
# Fetch current subscriber status from RevenueCat
|
|
subscriber_data = await fetch_subscriber_from_revenuecat(user_id_to_use)
|
|
|
|
if not subscriber_data:
|
|
logger.error(f"Failed to fetch subscriber data for {user_id_to_use}")
|
|
return {"status": "error", "reason": "fetch failed"}
|
|
|
|
# Sync to our database
|
|
success = sync_subscription_status(subscriber_data, db)
|
|
|
|
if not success:
|
|
logger.error(f"Failed to sync subscription for user {user_id_to_use}")
|
|
return {"status": "error", "reason": "sync failed"}
|
|
|
|
# Log billing event for audit trail
|
|
try:
|
|
user_uuid = UUID(user_id_to_use)
|
|
subscription = get_or_create_subscription(user_uuid, db)
|
|
if subscription:
|
|
create_billing_event(
|
|
user_id=subscription.user_id,
|
|
subscription_id=subscription.id,
|
|
event_type=f"revenuecat_webhook_{event_type}",
|
|
event_data=json.dumps(payload),
|
|
provider_event_id=event_id,
|
|
db=db,
|
|
)
|
|
except ValueError:
|
|
logger.error(f"Invalid user ID format in webhook: {user_id_to_use}")
|
|
return {"status": "error", "reason": "invalid user id"}
|
|
|
|
return {
|
|
"status": "processed",
|
|
"event_type": event_type,
|
|
}
|
|
|
|
except json.JSONDecodeError:
|
|
logger.error("Invalid JSON in webhook payload")
|
|
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
|
except Exception as e:
|
|
logger.error(f"Webhook processing error: {e}")
|
|
return {"status": "error", "error": str(e)}
|