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
This commit was merged in pull request #69.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user