Files
chat_backend/llm_be/monetization/services/revenuecat.py
T
westfarn e1e086a474
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / docker (push) Successful in 21s
Deploy Beta / deploy-beta (push) Successful in 50s
Monetization app + RevenueCat webhooks (store IAP ledger) (#69)
## Summary
- Rename `finance` → **`monetization`** Django app (keep `finance_*` tables via `label = "finance"`)
- Add `services/stripe.py` + `services/revenuecat.py`; RevenueCat webhook upserts **subscription + Invoice/Payment** (billing history parity with Stripe)
- Mount `/api/monetization/` + keep `/api/finance/` alias
- Extend `Source`/`Provider` with `revenuecat`; product→plan mapping via `revenuecat_product_id` / `REVENUECAT_PRODUCT_PLAN_MAP`

Closes #68. Companion to [chat_web_app#100](ai_ml_operations/chat_web_app#100).

## Test plan
- [x] `manage.py test monetization.tests` (54 OK)
- [x] Smoke `chat_backend.tests.test_views_documents` + `test_oauth`
- [ ] Deploy: set `REVENUECAT_WEBHOOK_SECRET`; point RC webhook at `/api/finance/webhooks/revenuecat/`
- [ ] Map store product IDs on `SubscriptionPlan.revenuecat_product_id` (or env JSON map)
- [ ] Sandbox INITIAL_PURCHASE → subscription `source=revenuecat` + invoice in `/finance/invoices/`Reviewed-on: #69
2026-08-04 03:40:15 -07:00

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)