"""RevenueCat store IAP helpers and webhook dispatch (ledger + entitlements).""" from __future__ import annotations import logging from datetime import datetime, timezone as dt_timezone from typing import Any from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone from monetization.models import Invoice, Payment, UserSubscription from monetization.services.plans import ( assign_plan_from_revenuecat, get_or_create_user_subscription, log_subscription_auth_event, resolve_plan_from_revenuecat_product, ) logger = logging.getLogger(__name__) User = get_user_model() # Events that grant or refresh paid access. _ACTIVE_EVENT_TYPES = frozenset( { "INITIAL_PURCHASE", "RENEWAL", "UNCANCELLATION", "NON_RENEWING_PURCHASE", "PRODUCT_CHANGE", "SUBSCRIPTION_EXTENDED", } ) # Still entitled until period end (cancel scheduled). _CANCEL_AT_PERIOD_END_TYPES = frozenset({"CANCELLATION"}) # Access ended / payment problems. _EXPIRED_EVENT_TYPES = frozenset({"EXPIRATION"}) _BILLING_ISSUE_TYPES = frozenset({"BILLING_ISSUE"}) class RevenueCatWebhookAuthError(ValueError): """Invalid or missing RevenueCat webhook Authorization header.""" def verify_revenuecat_authorization( *, authorization_header: str | None, expected_secret: str, ) -> None: """Validate ``Authorization: Bearer `` (or raw secret).""" if not expected_secret: raise RevenueCatWebhookAuthError("REVENUECAT_WEBHOOK_SECRET is not configured") header = (authorization_header or "").strip() if not header: raise RevenueCatWebhookAuthError("Missing Authorization header") token = header if header.lower().startswith("bearer "): token = header[7:].strip() if token != expected_secret: raise RevenueCatWebhookAuthError("Invalid Authorization token") def _ms_to_dt(value: int | float | None): if value is None: return None try: ms = int(value) except (TypeError, ValueError): return None if ms <= 0: return None return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc) def _price_to_cents(event: dict[str, Any]) -> int: """RevenueCat ``price`` is major units in USD; prefer purchased currency.""" raw = event.get("price_in_purchased_currency") if raw is None: raw = event.get("price") try: return max(0, int(round(float(raw or 0) * 100))) except (TypeError, ValueError): return 0 def _resolve_user_from_app_user_id(app_user_id: str | None): if not app_user_id: return None # Prefer numeric PK (what the Capacitor client should send via Purchases.logIn). try: return User.objects.get(pk=int(str(app_user_id).strip())) except (User.DoesNotExist, TypeError, ValueError): pass # Fallback: email as app user id. user = User.objects.filter(email__iexact=str(app_user_id).strip()).first() if user: return user logger.warning("RevenueCat webhook: app_user_id=%s not found", app_user_id) return None def _store_label(store: str | None) -> str: return (store or "").strip().upper() def _description_for_event(event: dict[str, Any]) -> str: store = _store_label(event.get("store")) product = event.get("product_id") or "subscription" etype = event.get("type") or "purchase" parts = [f"Store IAP ({store})" if store else "Store IAP", product, etype] return " — ".join(p for p in parts if p) @transaction.atomic def upsert_invoice_from_revenuecat( *, user, event: dict[str, Any], status: str, ) -> Invoice: event_id = event.get("id") if not event_id: raise ValueError("RevenueCat event missing id") amount = _price_to_cents(event) currency = (event.get("currency") or "usd").lower() period_start = _ms_to_dt(event.get("purchased_at_ms")) period_end = _ms_to_dt(event.get("expiration_at_ms")) amount_paid = amount if status == Invoice.Status.PAID else 0 invoice, _created = Invoice.objects.update_or_create( revenuecat_event_id=event_id, defaults={ "user": user, "company": getattr(user, "company", None), "provider": Invoice.Provider.REVENUECAT, "status": status, "currency": currency, "amount_due": amount, "amount_paid": amount_paid, "period_start": period_start, "period_end": period_end, "revenuecat_store": _store_label(event.get("store")), "description": _description_for_event(event), "hosted_invoice_url": "", }, ) return invoice @transaction.atomic def upsert_payment_from_revenuecat( *, user, invoice: Invoice | None, event: dict[str, Any], status: str, failure_message: str = "", ) -> Payment | None: txn_id = event.get("transaction_id") or event.get("id") if not txn_id: return None amount = _price_to_cents(event) currency = (event.get("currency") or "usd").lower() paid_at = ( _ms_to_dt(event.get("purchased_at_ms")) if status == Payment.Status.SUCCEEDED else None ) or (timezone.now() if status == Payment.Status.SUCCEEDED else None) payment, _created = Payment.objects.update_or_create( revenuecat_transaction_id=str(txn_id), defaults={ "user": user, "company": getattr(user, "company", None), "invoice": invoice, "provider": Payment.Provider.REVENUECAT, "status": status, "currency": currency, "amount": amount, "paid_at": paid_at, "failure_message": failure_message or "", }, ) return payment def handle_revenuecat_event(event: dict[str, Any]): """Apply one RevenueCat ``event`` object: subscription + invoice/payment.""" event_type = (event.get("type") or "").upper() app_user_id = event.get("app_user_id") or event.get("original_app_user_id") user = _resolve_user_from_app_user_id(app_user_id) if user is None: # TRANSFER may use different fields; still log. logger.error( "RevenueCat %s: cannot resolve user app_user_id=%s event=%s", event_type, app_user_id, event.get("id"), ) return None product_id = event.get("product_id") original_txn = ( event.get("original_transaction_id") or event.get("transaction_id") or "" ) period_end = _ms_to_dt(event.get("expiration_at_ms")) plan = resolve_plan_from_revenuecat_product(product_id) if event_type in _ACTIVE_EVENT_TYPES: invoice = upsert_invoice_from_revenuecat( user=user, event=event, status=Invoice.Status.PAID ) upsert_payment_from_revenuecat( user=user, invoice=invoice, event=event, status=Payment.Status.SUCCEEDED, ) return assign_plan_from_revenuecat( user, plan_slug=plan.slug if plan else None, product_id=product_id, revenuecat_original_transaction_id=str(original_txn), status=UserSubscription.Status.ACTIVE, cancel_at_period_end=False, current_period_end=period_end, keep_existing_plan_if_unknown=True, ) if event_type in _CANCEL_AT_PERIOD_END_TYPES: # User canceled in store; access continues until expiration. invoice = upsert_invoice_from_revenuecat( user=user, event=event, status=Invoice.Status.OPEN ) return assign_plan_from_revenuecat( user, plan_slug=plan.slug if plan else None, product_id=product_id, revenuecat_original_transaction_id=str(original_txn), status=UserSubscription.Status.ACTIVE, cancel_at_period_end=True, current_period_end=period_end, keep_existing_plan_if_unknown=True, ) if event_type in _BILLING_ISSUE_TYPES: invoice = upsert_invoice_from_revenuecat( user=user, event=event, status=Invoice.Status.PAYMENT_FAILED ) upsert_payment_from_revenuecat( user=user, invoice=invoice, event=event, status=Payment.Status.FAILED, failure_message="Store billing issue", ) return assign_plan_from_revenuecat( user, plan_slug=plan.slug if plan else None, product_id=product_id, revenuecat_original_transaction_id=str(original_txn), status=UserSubscription.Status.PAST_DUE, current_period_end=period_end, keep_existing_plan_if_unknown=True, ) if event_type in _EXPIRED_EVENT_TYPES: upsert_invoice_from_revenuecat( user=user, event=event, status=Invoice.Status.VOID ) sub = get_or_create_user_subscription(user) prev_status = sub.status sub.status = UserSubscription.Status.CANCELED sub.cancel_at_period_end = False if period_end: sub.current_period_end = period_end if original_txn: sub.revenuecat_original_transaction_id = str(original_txn) if sub.source == UserSubscription.Source.NONE: sub.source = UserSubscription.Source.REVENUECAT elif sub.source != UserSubscription.Source.REVENUECAT: # Only expire if this was a store sub; leave Stripe alone. if sub.source == UserSubscription.Source.STRIPE: logger.info( "Ignoring RC EXPIRATION for Stripe-sourced user=%s", user.pk ) return sub sub.source = UserSubscription.Source.REVENUECAT sub.save() if prev_status != UserSubscription.Status.CANCELED: log_subscription_auth_event( user, started=False, detail=( f"plan={sub.plan.slug if sub.plan_id else 'none'} " f"source={sub.source} status={sub.status} " f"revenuecat_original_transaction_id=" f"{sub.revenuecat_original_transaction_id}" ), ) return sub logger.info("Ignoring unhandled RevenueCat event type: %s", event_type) return None def dispatch_revenuecat_event(payload: dict[str, Any]): """Route a verified RevenueCat webhook JSON body.""" event = payload.get("event") if isinstance(payload.get("event"), dict) else payload if not isinstance(event, dict): raise ValueError("RevenueCat payload missing event object") return handle_revenuecat_event(event)