Rename finance → monetization (keep finance_* tables via app label), add RevenueCat webhook + ledger upserts so store IAP syncs subscriptions and billing history like Stripe. Companion to chat_web_app#100 / #68.
320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""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 <secret>`` (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)
|