Grant mobile plans from verified App Store and Play purchases (#78) #79

Merged
westfarn merged 1 commits from feat/78-native-store-purchases into master 2026-10-19 04:37:49 -07:00
14 changed files with 1141 additions and 119 deletions

No files matched your search

+6 -2
View File
@@ -83,8 +83,12 @@ STRIPE_WEBHOOK_SECRET=
# Optional: pre-created Stripe Price ID for Founders. When empty, Checkout uses
# SubscriptionPlan.price_cents / SUBSCRIPTION_PRICE_* ($10 USD / month Founders).
STRIPE_PRICE_ID=
# RevenueCat webhook Authorization bearer secret (store IAP).
REVENUECAT_WEBHOOK_SECRET=
# Native store purchases (#78). iOS verifies the StoreKit signed transaction
# against Apple Root CA G3. Android calls the Play Developer API.
APPLE_IAP_BUNDLE_ID=ai.hesychia.chat
GOOGLE_PLAY_PACKAGE_NAME=ai.hesychia.chat
# Service-account JSON (one line) or a path to the JSON file. Empty disables Play confirm.
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=
# Optional JSON map of store product id → plan slug, e.g.
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
# SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000
+3 -1
View File
@@ -91,7 +91,9 @@ OAUTH_CALLBACK_BASE_URL=https://chatbackend.aimloperations.com
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
STRIPE_WEBHOOK_SECRET=replace-with-stripe-webhook-secret
REVENUECAT_WEBHOOK_SECRET=replace-with-revenuecat-webhook-auth-token
APPLE_IAP_BUNDLE_ID=ai.hesychia.chat
GOOGLE_PLAY_PACKAGE_NAME=ai.hesychia.chat
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=replace-with-play-console-service-account-json
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
# Optional: pre-created Stripe Price ID. When empty, Checkout uses
# SUBSCRIPTION_PRICE_* from settings.py ($10 USD / month by default).
+11 -5
View File
@@ -99,7 +99,9 @@ with `COMPOSE_DATABASE_URL` if needed.
| `CAPTCHA_SECRET_KEY` | empty | recommended | |
| `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready |
| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook (`monetization` app) |
| `REVENUECAT_WEBHOOK_SECRET` | empty | yes for store IAP | RevenueCat webhook Authorization bearer |
| `APPLE_IAP_BUNDLE_ID` | `ai.hesychia.chat` | yes for iOS IAP | Must match the signed StoreKit transaction |
| `GOOGLE_PLAY_PACKAGE_NAME` | `ai.hesychia.chat` | yes for Play IAP | Android application id |
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | empty | yes for Play IAP | Play Console service-account JSON (or a file path) |
| `REVENUECAT_PRODUCT_PLAN_MAP` | empty JSON | optional | `{"product_id":"plan_slug"}` fallback map |
| `STRIPE_PRICE_ID` | empty | optional | Pre-created Price; else `$10/mo` from settings |
| `GOOGLE_OAUTH_CLIENT_ID` / `..._SECRET` | empty | for SSO/Drive | Also used for Drive linking (#47), incremental scopes |
@@ -286,10 +288,14 @@ URLs: `/api/monetization/...` and alias `/api/finance/...`.
(`POST /api/finance/portal/`). Local state syncs via
`customer.subscription.updated` / `deleted` webhooks.
**Native (Play / App Store):** RevenueCat webhooks at
`POST /api/finance/webhooks/revenuecat/` (Authorization bearer =
`REVENUECAT_WEBHOOK_SECRET`) upsert Invoice/Payment ledger rows and sync
`UserSubscription` (`source=revenuecat`).
**Native (Play / App Store):** the app buys through StoreKit or Play Billing,
then `POST /api/finance/store/confirm/` with the signed App Store transaction
or the Play purchase token. The backend verifies it (Apple certificate chain,
or Play Developer API `purchases.subscriptionsv2`) and upserts the ledger.
`source` is `app_store` or `play_store`. There is no store webhook. Renewals
are another confirm from the app; access ends when `current_period_end` passes.
`GET /api/finance/subscription/` includes `store_account_token` to pass into
the store purchase as `appAccountToken` / `obfuscatedAccountId`.
`GET /api/finance/subscription/` includes `cancel_at_period_end` and
`current_period_end` for Account UI messaging.
+13 -1
View File
@@ -408,8 +408,20 @@ STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "") or ""
# price_data built from SUBSCRIPTION_PRICE_* below.
STRIPE_PRICE_ID = env("STRIPE_PRICE_ID", "") or ""
# RevenueCat webhook Authorization bearer secret + optional product→plan map.
# Legacy RevenueCat webhook secret. Store grants no longer use that webhook.
REVENUECAT_WEBHOOK_SECRET = env("REVENUECAT_WEBHOOK_SECRET", "") or ""
# Native store verification. Apple signed transactions chain to the bundled
# Apple Root CA G3 unless APPLE_IAP_ROOT_PEM adds another PEM.
APPLE_IAP_BUNDLE_ID = (
env("APPLE_IAP_BUNDLE_ID", "ai.hesychia.chat") or "ai.hesychia.chat"
)
APPLE_IAP_ROOT_PEM = env("APPLE_IAP_ROOT_PEM", "") or ""
GOOGLE_PLAY_PACKAGE_NAME = (
env("GOOGLE_PLAY_PACKAGE_NAME", "ai.hesychia.chat") or "ai.hesychia.chat"
)
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON = (
env("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or ""
)
_rc_product_map_raw = env("REVENUECAT_PRODUCT_PLAN_MAP", "") or ""
REVENUECAT_PRODUCT_PLAN_MAP: dict = {}
if _rc_product_map_raw.strip():
@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
6BgD56KyKA==
-----END CERTIFICATE-----
@@ -0,0 +1,56 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0005_revenuecat_fields"),
]
operations = [
migrations.AlterField(
model_name="invoice",
name="provider",
field=models.CharField(
choices=[
("stripe", "Stripe"),
("revenuecat", "RevenueCat"),
("app_store", "App Store"),
("play_store", "Play Store"),
],
default="stripe",
max_length=32,
),
),
migrations.AlterField(
model_name="payment",
name="provider",
field=models.CharField(
choices=[
("stripe", "Stripe"),
("revenuecat", "RevenueCat"),
("app_store", "App Store"),
("play_store", "Play Store"),
],
default="stripe",
max_length=32,
),
),
migrations.AlterField(
model_name="usersubscription",
name="source",
field=models.CharField(
choices=[
("none", "None"),
("stripe", "Stripe"),
("revenuecat", "RevenueCat"),
("app_store", "App Store"),
("play_store", "Play Store"),
("backer", "Backer"),
("admin", "Admin"),
],
default="none",
max_length=32,
),
),
]
+6
View File
@@ -134,6 +134,8 @@ class UserSubscription(TimeInfoBase):
NONE = "none", "None"
STRIPE = "stripe", "Stripe"
REVENUECAT = "revenuecat", "RevenueCat"
APP_STORE = "app_store", "App Store"
PLAY_STORE = "play_store", "Play Store"
BACKER = "backer", "Backer"
ADMIN = "admin", "Admin"
@@ -214,6 +216,8 @@ class Invoice(TimeInfoBase):
class Provider(models.TextChoices):
STRIPE = "stripe", "Stripe"
REVENUECAT = "revenuecat", "RevenueCat"
APP_STORE = "app_store", "App Store"
PLAY_STORE = "play_store", "Play Store"
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
@@ -308,6 +312,8 @@ class Payment(TimeInfoBase):
class Provider(models.TextChoices):
STRIPE = "stripe", "Stripe"
REVENUECAT = "revenuecat", "RevenueCat"
APP_STORE = "app_store", "App Store"
PLAY_STORE = "play_store", "Play Store"
class Status(models.TextChoices):
PENDING = "pending", "Pending"
+16
View File
@@ -59,8 +59,23 @@ class PortalSessionSerializer(serializers.Serializer):
return_url = serializers.URLField(required=False, allow_blank=False)
class StorePurchaseConfirmSerializer(serializers.Serializer):
store = serializers.ChoiceField(choices=("app_store", "play_store"))
product_id = serializers.CharField(max_length=255)
transaction_id = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
purchase_token = serializers.CharField(
max_length=4096, required=False, allow_blank=True
)
signed_transaction = serializers.CharField(required=False, allow_blank=True)
class SubscriptionPlanSerializer(serializers.ModelSerializer):
features = serializers.SerializerMethodField()
store_product_id = serializers.CharField(
source="revenuecat_product_id", read_only=True
)
class Meta:
model = SubscriptionPlan
@@ -78,6 +93,7 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
"prompt_window_hours",
"monthly_token_quota",
"sort_order",
"store_product_id",
]
read_only_fields = fields
+5 -2
View File
@@ -247,6 +247,8 @@ def needs_checkout(user) -> bool:
UserSubscription.Source.ADMIN,
UserSubscription.Source.STRIPE,
UserSubscription.Source.REVENUECAT,
UserSubscription.Source.APP_STORE,
UserSubscription.Source.PLAY_STORE,
):
return False
return True
@@ -457,8 +459,9 @@ def assign_plan_from_revenuecat(
cancel_at_period_end: bool | None = None,
current_period_end=None,
keep_existing_plan_if_unknown: bool = False,
source: str | None = None,
) -> UserSubscription:
"""Assign a catalog plan from a RevenueCat store purchase event."""
"""Assign a catalog plan from a verified store purchase."""
seed_subscription_plans(update_existing=False)
existing = (
UserSubscription.objects.filter(user=user).select_related("plan").first()
@@ -493,7 +496,7 @@ def assign_plan_from_revenuecat(
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.REVENUECAT,
source=source or UserSubscription.Source.REVENUECAT,
status=status,
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
log_auth_event=False,
@@ -0,0 +1,617 @@
"""Verify App Store and Play Store purchases and grant entitlements.
The mobile app completes the purchase with StoreKit or Play Billing, then
POSTs the signed transaction or purchase token. This module checks that
payload with Apple or Google and writes the subscription ledger. Store
webhooks are not accepted.
"""
from __future__ import annotations
import base64
import json
import logging
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone as dt_timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote
import jwt
import requests
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, padding
from django.conf import settings
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,
log_subscription_auth_event,
resolve_plan_from_revenuecat_product,
)
logger = logging.getLogger(__name__)
# uuid5(NAMESPACE_URL, this name). Do not change; clients receive the result.
_STORE_ACCOUNT_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://chat.aimloperations.com/store-account",
)
_APP_STORE_CERT_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
_PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
_BUNDLED_APPLE_ROOT = (
Path(__file__).resolve().parent.parent / "certs" / "apple_root_g3.pem"
)
_STORE_SOURCES = frozenset(
{
UserSubscription.Source.APP_STORE,
UserSubscription.Source.PLAY_STORE,
UserSubscription.Source.REVENUECAT,
}
)
class StoreNotConfiguredError(Exception):
"""Apple or Google credentials required to verify a purchase are missing."""
class StorePurchaseRejected(Exception):
"""The store payload is not an active purchase for this user."""
class StoreVerificationError(Exception):
"""The store API could not be reached."""
@dataclass(frozen=True)
class VerifiedStorePurchase:
source: str
store_label: str
product_id: str
transaction_id: str
original_transaction_id: str
expires_at: datetime | None
purchased_at: datetime | None
cancel_at_period_end: bool
status: str
price_cents: int
currency: str
account_token: str
def store_account_token(user_id: int) -> str:
"""Stable store account id for this user. Pass it to StoreKit and Play."""
return str(uuid.uuid5(_STORE_ACCOUNT_NAMESPACE, str(user_id)))
def expire_lapsed_store_subscription(sub: UserSubscription | None):
"""End store access once the verified period has passed.
Renewals arrive when the app confirms the store entitlement again.
"""
if sub is None or sub.source not in _STORE_SOURCES:
return sub
if sub.status != UserSubscription.Status.ACTIVE or not sub.current_period_end:
return sub
if sub.current_period_end > timezone.now():
return sub
prev_status = sub.status
sub.status = UserSubscription.Status.CANCELED
sub.cancel_at_period_end = False
sub.save(update_fields=["status", "cancel_at_period_end", "last_modified"])
if prev_status != UserSubscription.Status.CANCELED:
log_subscription_auth_event(
sub.user,
started=False,
detail=(
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
f"source={sub.source} status={sub.status} "
"reason=store_period_ended"
),
)
return sub
def confirm_store_purchase(user, payload: dict[str, Any]) -> UserSubscription:
"""Verify one store purchase for the authenticated user and grant it."""
store = (payload.get("store") or "").strip().lower()
product_id = (payload.get("product_id") or "").strip()
if store == UserSubscription.Source.APP_STORE:
verified = verify_apple_signed_transaction(
signed_transaction=(payload.get("signed_transaction") or "").strip(),
product_id=product_id,
transaction_id=(payload.get("transaction_id") or "").strip(),
)
elif store == UserSubscription.Source.PLAY_STORE:
verified = verify_google_subscription(
product_id=product_id,
purchase_token=(payload.get("purchase_token") or "").strip(),
transaction_id=(payload.get("transaction_id") or "").strip(),
)
else:
raise StorePurchaseRejected("store must be app_store or play_store.")
if verified.product_id != product_id:
raise StorePurchaseRejected("Store product does not match this purchase.")
return grant_verified_store_purchase(user, verified)
def verify_apple_signed_transaction(
*,
signed_transaction: str,
product_id: str,
transaction_id: str = "",
) -> VerifiedStorePurchase:
"""Verify a StoreKit 2 signed transaction against Apple's root."""
if not signed_transaction:
raise StorePurchaseRejected("App Store purchases require signed_transaction.")
bundle_id = (getattr(settings, "APPLE_IAP_BUNDLE_ID", "") or "").strip()
if not bundle_id:
raise StoreNotConfiguredError("APPLE_IAP_BUNDLE_ID is not configured.")
roots = _load_apple_roots()
if not roots:
raise StoreNotConfiguredError("No Apple root certificate is configured.")
try:
header = jwt.get_unverified_header(signed_transaction)
except jwt.PyJWTError as exc:
raise StorePurchaseRejected("Invalid App Store signed transaction.") from exc
x5c = header.get("x5c") if isinstance(header, dict) else None
if not isinstance(x5c, list) or not x5c:
raise StorePurchaseRejected("App Store transaction is missing its certificate.")
try:
certs = [_load_der_cert(item) for item in x5c]
leaf = _trusted_leaf(certs, roots)
except StorePurchaseRejected:
raise
except Exception as exc:
raise StorePurchaseRejected("App Store certificate chain is not trusted.") from exc
_require_app_store_oid(leaf)
try:
claims = jwt.decode(
signed_transaction,
leaf.public_key(),
algorithms=["ES256"],
options={"verify_aud": False, "verify_exp": False},
)
except jwt.PyJWTError as exc:
raise StorePurchaseRejected("App Store transaction signature is invalid.") from exc
if not isinstance(claims, dict):
raise StorePurchaseRejected("App Store transaction payload is invalid.")
claim_bundle = str(claims.get("bundleId") or "")
if claim_bundle != bundle_id:
raise StorePurchaseRejected("App Store bundle id does not match this app.")
claim_product = str(claims.get("productId") or "")
if product_id and claim_product != product_id:
raise StorePurchaseRejected("App Store product does not match this purchase.")
claim_txn = str(claims.get("transactionId") or "")
if transaction_id and claim_txn and transaction_id != claim_txn:
raise StorePurchaseRejected("App Store transaction id does not match.")
if not claim_txn:
raise StorePurchaseRejected("App Store transaction id is missing.")
expires_at = _ms_to_dt(claims.get("expiresDate"))
purchased_at = _ms_to_dt(claims.get("purchaseDate"))
revoked = _ms_to_dt(claims.get("revocationDate")) is not None
now = timezone.now()
if revoked or (expires_at is not None and expires_at <= now):
status = UserSubscription.Status.CANCELED
else:
status = UserSubscription.Status.ACTIVE
price_cents = 0
raw_price = claims.get("price")
try:
if raw_price is not None:
price_cents = max(0, int(raw_price) // 10)
except (TypeError, ValueError):
price_cents = 0
currency = str(claims.get("currency") or "usd").lower()
original = str(claims.get("originalTransactionId") or claim_txn)
return VerifiedStorePurchase(
source=UserSubscription.Source.APP_STORE,
store_label="APP_STORE",
product_id=claim_product,
transaction_id=claim_txn,
original_transaction_id=original,
expires_at=expires_at,
purchased_at=purchased_at,
cancel_at_period_end=False,
status=status,
price_cents=price_cents,
currency=currency,
account_token=str(claims.get("appAccountToken") or ""),
)
def verify_google_subscription(
*,
product_id: str,
purchase_token: str,
transaction_id: str = "",
) -> VerifiedStorePurchase:
"""Verify a Play Billing purchase token with the Android Publisher API."""
if not purchase_token:
raise StorePurchaseRejected("Play Store purchases require purchase_token.")
if not product_id:
raise StorePurchaseRejected("Play Store purchases require product_id.")
body = fetch_google_subscription(purchase_token)
return _verified_from_google(
body,
product_id=product_id,
purchase_token=purchase_token,
transaction_id=transaction_id,
)
def fetch_google_subscription(purchase_token: str) -> dict[str, Any]:
"""GET purchases.subscriptionsv2 for this token. Tests patch this."""
package_name = (getattr(settings, "GOOGLE_PLAY_PACKAGE_NAME", "") or "").strip()
raw = (getattr(settings, "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or "").strip()
if not package_name or not raw:
raise StoreNotConfiguredError(
"Google Play verification is not configured "
"(GOOGLE_PLAY_PACKAGE_NAME and GOOGLE_PLAY_SERVICE_ACCOUNT_JSON)."
)
info = _load_service_account_info(raw)
try:
access_token = _google_access_token(info)
url = (
"https://androidpublisher.googleapis.com/androidpublisher/v3/"
f"applications/{quote(package_name, safe='')}/purchases/"
f"subscriptionsv2/tokens/{quote(purchase_token, safe='')}"
)
response = requests.get(
url,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
except (requests.RequestException, ValueError, OSError) as exc:
logger.exception("Play Store verification request failed")
raise StoreVerificationError("Play Store verification failed.") from exc
if response.status_code == 404:
raise StorePurchaseRejected("Play Store purchase was not found.")
if response.status_code >= 400:
logger.error(
"Play Store verification HTTP %s",
response.status_code,
)
if response.status_code >= 500:
raise StoreVerificationError("Play Store verification failed.")
raise StorePurchaseRejected("Play Store rejected this purchase token.")
try:
body = response.json()
except ValueError as exc:
raise StoreVerificationError("Play Store returned an invalid response.") from exc
if not isinstance(body, dict):
raise StoreVerificationError("Play Store returned an invalid response.")
return body
def _verified_from_google(
body: dict[str, Any],
*,
product_id: str,
purchase_token: str,
transaction_id: str,
) -> VerifiedStorePurchase:
line_items = body.get("lineItems") or []
match = None
if isinstance(line_items, list):
for item in line_items:
if isinstance(item, dict) and str(item.get("productId") or "") == product_id:
match = item
break
if match is None:
raise StorePurchaseRejected("Play Store product does not match this purchase.")
state = str(body.get("subscriptionState") or "")
expires_at = _parse_rfc3339(match.get("expiryTime"))
now = timezone.now()
expired = expires_at is not None and expires_at <= now
cancel_at_period_end = False
if state in {"SUBSCRIPTION_STATE_PENDING", "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED"}:
raise StorePurchaseRejected("Play Store purchase is still pending.")
if state in {"SUBSCRIPTION_STATE_ACTIVE", "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"} and not expired:
status = UserSubscription.Status.ACTIVE
elif state == "SUBSCRIPTION_STATE_CANCELED" and not expired:
status = UserSubscription.Status.ACTIVE
cancel_at_period_end = True
elif state == "SUBSCRIPTION_STATE_ON_HOLD":
status = UserSubscription.Status.PAST_DUE
else:
status = UserSubscription.Status.CANCELED
auto_renew = (match.get("autoRenewingPlan") or {}) if isinstance(match, dict) else {}
if isinstance(auto_renew, dict) and auto_renew.get("autoRenewEnabled") is False and not expired:
if status == UserSubscription.Status.ACTIVE:
cancel_at_period_end = True
order_id = str(match.get("latestSuccessfulOrderId") or transaction_id or purchase_token)
identifiers = body.get("externalAccountIdentifiers") or {}
account_token = ""
if isinstance(identifiers, dict):
account_token = str(identifiers.get("obfuscatedExternalAccountId") or "")
price_cents, currency = _google_price(auto_renew if isinstance(auto_renew, dict) else {})
return VerifiedStorePurchase(
source=UserSubscription.Source.PLAY_STORE,
store_label="PLAY_STORE",
product_id=product_id,
transaction_id=order_id,
original_transaction_id=purchase_token,
expires_at=expires_at,
purchased_at=None,
cancel_at_period_end=cancel_at_period_end,
status=status,
price_cents=price_cents,
currency=currency,
account_token=account_token,
)
@transaction.atomic
def grant_verified_store_purchase(user, verified: VerifiedStorePurchase) -> UserSubscription:
plan = resolve_plan_from_revenuecat_product(verified.product_id)
if plan is None:
raise StorePurchaseRejected("This store product is not mapped to a plan.")
expected = store_account_token(user.pk)
if verified.account_token and verified.account_token.lower() != expected.lower():
raise StorePurchaseRejected("Purchase account token does not match this user.")
original = verified.original_transaction_id
if original:
taken = (
UserSubscription.objects.select_for_update()
.filter(revenuecat_original_transaction_id=original)
.exclude(user=user)
.exists()
)
if taken:
raise StorePurchaseRejected(
"This purchase is already linked to another account."
)
sub = assign_plan_from_revenuecat(
user,
plan_slug=plan.slug,
product_id=verified.product_id,
revenuecat_original_transaction_id=original,
status=verified.status,
cancel_at_period_end=verified.cancel_at_period_end,
current_period_end=verified.expires_at,
keep_existing_plan_if_unknown=False,
source=verified.source,
)
if verified.status != UserSubscription.Status.ACTIVE:
return sub
amount = verified.price_cents or plan.price_cents
currency = (verified.currency or plan.currency or "usd").lower()
event_id = f"{verified.source}:{verified.transaction_id}"
invoice, _created = Invoice.objects.update_or_create(
revenuecat_event_id=event_id,
defaults={
"user": user,
"company": getattr(user, "company", None),
"provider": verified.source,
"status": Invoice.Status.PAID,
"currency": currency,
"amount_due": amount,
"amount_paid": amount,
"period_start": verified.purchased_at,
"period_end": verified.expires_at,
"revenuecat_store": verified.store_label,
"description": f"{plan.name} — {verified.store_label}",
"hosted_invoice_url": "",
},
)
Payment.objects.update_or_create(
revenuecat_transaction_id=verified.transaction_id,
defaults={
"user": user,
"company": getattr(user, "company", None),
"invoice": invoice,
"provider": verified.source,
"status": Payment.Status.SUCCEEDED,
"currency": currency,
"amount": amount,
"paid_at": verified.purchased_at or timezone.now(),
"failure_message": "",
},
)
return sub
def _load_apple_roots() -> list[x509.Certificate]:
pems: list[str] = []
extra = (getattr(settings, "APPLE_IAP_ROOT_PEM", "") or "").strip()
if extra:
pems.append(extra)
if _BUNDLED_APPLE_ROOT.is_file():
pems.append(_BUNDLED_APPLE_ROOT.read_text(encoding="utf-8"))
roots: list[x509.Certificate] = []
for pem in pems:
try:
roots.append(x509.load_pem_x509_certificate(pem.encode("utf-8")))
except ValueError:
logger.error("Ignoring an unreadable Apple root certificate")
return roots
def _load_der_cert(value: Any) -> x509.Certificate:
if not isinstance(value, str) or not value.strip():
raise StorePurchaseRejected("App Store certificate is missing.")
try:
der = base64.b64decode(value)
return x509.load_der_x509_certificate(der)
except (ValueError, TypeError) as exc:
raise StorePurchaseRejected("App Store certificate is invalid.") from exc
def _trusted_leaf(
certs: list[x509.Certificate],
roots: list[x509.Certificate],
) -> x509.Certificate:
if not certs:
raise StorePurchaseRejected("App Store certificate chain is empty.")
now = datetime.now(dt_timezone.utc)
for cert in certs:
if cert.not_valid_before_utc > now or cert.not_valid_after_utc < now:
raise StorePurchaseRejected("App Store certificate is not currently valid.")
for index in range(len(certs) - 1):
_verify_signed_by(certs[index], certs[index + 1])
if certs[index].issuer != certs[index + 1].subject:
raise StorePurchaseRejected("App Store certificate issuer mismatch.")
last = certs[-1]
root_fps = {root.fingerprint(hashes.SHA256()) for root in roots}
if last.fingerprint(hashes.SHA256()) in root_fps:
return certs[0]
for root in roots:
try:
_verify_signed_by(last, root)
except Exception:
continue
if last.issuer == root.subject:
return certs[0]
raise StorePurchaseRejected("App Store certificate chain is not trusted.")
def _verify_signed_by(child: x509.Certificate, issuer: x509.Certificate) -> None:
public_key = issuer.public_key()
signature_hash = child.signature_hash_algorithm
if signature_hash is None:
raise StorePurchaseRejected("App Store certificate has no signature hash.")
if isinstance(public_key, ec.EllipticCurvePublicKey):
public_key.verify(
child.signature,
child.tbs_certificate_bytes,
ec.ECDSA(signature_hash),
)
return
public_key.verify(
child.signature,
child.tbs_certificate_bytes,
padding.PKCS1v15(),
signature_hash,
)
def _require_app_store_oid(leaf: x509.Certificate) -> None:
try:
leaf.extensions.get_extension_for_oid(_APP_STORE_CERT_OID)
except x509.ExtensionNotFound as exc:
raise StorePurchaseRejected(
"Signed transaction certificate is not an App Store certificate."
) from exc
def _google_access_token(info: dict[str, Any]) -> str:
"""OAuth JWT bearer grant for a Play Console service account."""
client_email = info.get("client_email")
private_key = info.get("private_key")
token_uri = info.get("token_uri") or "https://oauth2.googleapis.com/token"
if not client_email or not private_key:
raise StoreNotConfiguredError(
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is missing client_email or private_key."
)
now = int(time.time())
assertion = jwt.encode(
{
"iss": client_email,
"scope": _PLAY_SCOPE,
"aud": token_uri,
"iat": now,
"exp": now + 3600,
},
private_key,
algorithm="RS256",
)
try:
response = requests.post(
token_uri,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
},
timeout=15,
)
except requests.RequestException as exc:
raise StoreVerificationError("Play Store verification failed.") from exc
if response.status_code >= 400:
raise StoreVerificationError("Play Store authentication failed.")
try:
token = response.json().get("access_token")
except ValueError as exc:
raise StoreVerificationError("Play Store authentication failed.") from exc
if not token:
raise StoreVerificationError("Play Store authentication failed.")
return str(token)
def _load_service_account_info(raw: str) -> dict[str, Any]:
text = raw
if not raw.lstrip().startswith("{"):
path = Path(raw)
if path.is_file():
text = path.read_text(encoding="utf-8")
try:
info = json.loads(text)
except json.JSONDecodeError as exc:
raise StoreNotConfiguredError(
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not valid JSON."
) from exc
if not isinstance(info, dict):
raise StoreNotConfiguredError(
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be a JSON object."
)
return info
def _ms_to_dt(value: Any) -> datetime | None:
if value is None or value == "":
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 _parse_rfc3339(value: Any) -> datetime | None:
if not value or not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=dt_timezone.utc)
return parsed
def _google_price(auto_renew: dict[str, Any]) -> tuple[int, str]:
price = auto_renew.get("recurringPrice") if isinstance(auto_renew, dict) else None
if not isinstance(price, dict):
return 0, "usd"
currency = str(price.get("currencyCode") or "usd").lower()
try:
units = int(price.get("units") or 0)
except (TypeError, ValueError):
units = 0
try:
nanos = int(price.get("nanos") or 0)
except (TypeError, ValueError):
nanos = 0
cents = units * 100 + nanos // 10_000_000
return max(0, cents), currency
+1 -51
View File
@@ -2,10 +2,7 @@
from __future__ import annotations
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from django.test import TestCase
from chat_backend.tests.factories import make_user
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
@@ -129,50 +126,3 @@ class RevenueCatDispatchTests(TestCase):
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
@override_settings(REVENUECAT_WEBHOOK_SECRET="test-rc-secret")
class RevenueCatWebhookViewTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.client = APIClient()
self.url = reverse("finance_revenuecat_webhook")
self.user = make_user(email="rcview@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = "hesychia_founders_monthly"
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
def test_missing_secret_config(self):
with override_settings(REVENUECAT_WEBHOOK_SECRET=""):
response = self.client.post(
self.url, {"event": _rc_event()}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthorized(self):
response = self.client.post(
self.url,
{"event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer wrong",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_success(self):
response = self.client.post(
self.url,
{"api_version": "1.0", "event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer test-rc-secret",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
UserSubscription.objects.filter(
user=self.user,
source=UserSubscription.Source.REVENUECAT,
status=UserSubscription.Status.ACTIVE,
).exists()
)
self.assertTrue(
Invoice.objects.filter(
user=self.user, provider=Invoice.Provider.REVENUECAT
).exists()
)
@@ -0,0 +1,336 @@
"""StoreKit and Play Billing confirmation. No store webhooks."""
from __future__ import annotations
import base64
from datetime import datetime, timedelta, timezone as dt_timezone
from unittest.mock import patch
import jwt
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_user
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
from monetization.services.plans import seed_subscription_plans
from monetization.services.store_purchases import (
StoreNotConfiguredError,
StorePurchaseRejected,
confirm_store_purchase,
store_account_token,
)
_APP_STORE_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
_PRODUCT = "hesychia_founders_monthly"
def _cert(*, subject_cn, subject_key, issuer_cert, issuer_key, is_ca, app_store=False):
now = datetime.now(dt_timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject_cn)])
issuer_name = issuer_cert.subject if issuer_cert is not None else name
builder = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(issuer_name)
.public_key(subject_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=30))
)
if is_ca:
builder = builder.add_extension(
x509.BasicConstraints(ca=True, path_length=None),
critical=True,
)
if app_store:
builder = builder.add_extension(
x509.UnrecognizedExtension(_APP_STORE_OID, b"\x05\x00"),
critical=False,
)
signer = issuer_key if issuer_key is not None else subject_key
return builder.sign(signer, hashes.SHA256())
def _chain():
root_key = ec.generate_private_key(ec.SECP256R1())
leaf_key = ec.generate_private_key(ec.SECP256R1())
root = _cert(
subject_cn="Test Apple Root",
subject_key=root_key,
issuer_cert=None,
issuer_key=None,
is_ca=True,
)
leaf = _cert(
subject_cn="Test StoreKit",
subject_key=leaf_key,
issuer_cert=root,
issuer_key=root_key,
is_ca=False,
app_store=True,
)
root_pem = root.public_bytes(serialization.Encoding.PEM).decode("ascii")
return leaf_key, leaf, root, root_pem
def _b64_der(cert) -> str:
der = cert.public_bytes(serialization.Encoding.DER)
return base64.b64encode(der).decode("ascii")
def _signed_transaction(leaf_key, leaf, root, claims):
return jwt.encode(
claims,
leaf_key,
algorithm="ES256",
headers={"x5c": [_b64_der(leaf), _b64_der(root)]},
)
def _future_ms(days=30) -> int:
moment = datetime.now(dt_timezone.utc) + timedelta(days=days)
return int(moment.timestamp() * 1000)
class AppleConfirmTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="apple@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = _PRODUCT
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
self.leaf_key, self.leaf, self.root, self.root_pem = _chain()
def _claims(self, **overrides):
base = {
"bundleId": "ai.hesychia.chat",
"productId": _PRODUCT,
"transactionId": "2000000000001",
"originalTransactionId": "2000000000001",
"appAccountToken": store_account_token(self.user.pk),
"purchaseDate": _future_ms(days=-1),
"expiresDate": _future_ms(days=30),
"price": 10000,
"currency": "USD",
}
base.update(overrides)
return base
def _confirm(self, claims):
token = _signed_transaction(self.leaf_key, self.leaf, self.root, claims)
with override_settings(
APPLE_IAP_BUNDLE_ID="ai.hesychia.chat",
APPLE_IAP_ROOT_PEM=self.root_pem,
):
return confirm_store_purchase(
self.user,
{
"store": "app_store",
"product_id": _PRODUCT,
"transaction_id": claims["transactionId"],
"signed_transaction": token,
},
)
def test_signed_transaction_grants_plan(self):
sub = self._confirm(self._claims())
self.assertEqual(sub.source, UserSubscription.Source.APP_STORE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertEqual(sub.plan.slug, "founders")
self.assertEqual(sub.revenuecat_original_transaction_id, "2000000000001")
invoice = Invoice.objects.get(user=self.user)
self.assertEqual(invoice.provider, Invoice.Provider.APP_STORE)
self.assertEqual(invoice.amount_paid, 1000)
self.assertEqual(invoice.revenuecat_store, "APP_STORE")
payment = Payment.objects.get(user=self.user)
self.assertEqual(payment.provider, Payment.Provider.APP_STORE)
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
def test_repeat_confirm_is_idempotent(self):
claims = self._claims()
self._confirm(claims)
self._confirm(claims)
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 1)
self.assertEqual(Payment.objects.filter(user=self.user).count(), 1)
def test_wrong_account_token_rejected(self):
with self.assertRaises(StorePurchaseRejected):
self._confirm(self._claims(appAccountToken="00000000-0000-0000-0000-000000000000"))
def test_untrusted_chain_rejected(self):
token = _signed_transaction(
self.leaf_key, self.leaf, self.root, self._claims()
)
with override_settings(APPLE_IAP_BUNDLE_ID="ai.hesychia.chat", APPLE_IAP_ROOT_PEM=""):
with self.assertRaises(StorePurchaseRejected):
confirm_store_purchase(
self.user,
{
"store": "app_store",
"product_id": _PRODUCT,
"signed_transaction": token,
},
)
def test_revoked_transaction_cancels(self):
sub = self._confirm(self._claims(revocationDate=_future_ms(days=-1)))
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
self.assertFalse(Payment.objects.filter(user=self.user).exists())
class PlayConfirmTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="play@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = _PRODUCT
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
def _body(self, **overrides):
expiry = (datetime.now(dt_timezone.utc) + timedelta(days=30)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
body = {
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"lineItems": [
{
"productId": _PRODUCT,
"expiryTime": expiry,
"latestSuccessfulOrderId": "GPA.1234-5678",
"autoRenewingPlan": {
"autoRenewEnabled": True,
"recurringPrice": {
"currencyCode": "USD",
"units": "10",
"nanos": 0,
},
},
}
],
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": store_account_token(self.user.pk),
},
}
body.update(overrides)
return body
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_play_token_grants_plan(self, mock_fetch):
mock_fetch.return_value = self._body()
sub = confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-abc",
"transaction_id": "GPA.1234-5678",
},
)
self.assertEqual(sub.source, UserSubscription.Source.PLAY_STORE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertEqual(sub.revenuecat_original_transaction_id, "token-abc")
self.assertFalse(sub.cancel_at_period_end)
invoice = Invoice.objects.get(user=self.user)
self.assertEqual(invoice.provider, Invoice.Provider.PLAY_STORE)
self.assertEqual(invoice.amount_paid, 1000)
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_canceled_but_unexpired_keeps_access(self, mock_fetch):
mock_fetch.return_value = self._body(
subscriptionState="SUBSCRIPTION_STATE_CANCELED"
)
sub = confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-cancel",
},
)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertTrue(sub.cancel_at_period_end)
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_token_already_linked_to_someone_else(self, mock_fetch):
other = make_user(email="other@example.com")
UserSubscription.objects.create(
user=other,
plan=SubscriptionPlan.objects.get(slug="founders"),
status=UserSubscription.Status.ACTIVE,
source=UserSubscription.Source.PLAY_STORE,
revenuecat_original_transaction_id="token-taken",
)
mock_fetch.return_value = self._body()
with self.assertRaises(StorePurchaseRejected):
confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-taken",
},
)
def test_missing_play_credentials(self):
with override_settings(GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=""):
with self.assertRaises(StoreNotConfiguredError):
confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-abc",
},
)
class StoreConfirmApiTests(APITestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="api@example.com")
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_store_confirm")
def test_requires_auth(self):
self.client.force_authenticate(user=None)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@patch("monetization.views.confirm_store_purchase")
def test_rejected_purchase_is_400(self, mock_confirm):
mock_confirm.side_effect = StorePurchaseRejected("nope")
response = self.client.post(
self.url,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_lapsed_store_period_ends_on_read(self):
plan = SubscriptionPlan.objects.get(slug="founders")
UserSubscription.objects.create(
user=self.user,
plan=plan,
status=UserSubscription.Status.ACTIVE,
source=UserSubscription.Source.APP_STORE,
current_period_end=timezone.now() - timedelta(days=1),
)
response = self.client.get(reverse("finance_subscription_me"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["status"], "canceled")
self.assertTrue(response.data["needs_checkout"])
self.assertEqual(
response.data["store_account_token"],
store_account_token(self.user.pk),
)
+6 -6
View File
@@ -1,12 +1,12 @@
from django.urls import path
from monetization.views import (
ConfirmStorePurchaseView,
CreateBillingPortalSessionView,
CreateCheckoutSessionView,
InvoiceListView,
PaymentListView,
PlanListView,
RevenueCatWebhookView,
StripeWebhookView,
SubscriptionMeView,
)
@@ -42,14 +42,14 @@ urlpatterns = [
SubscriptionMeView.as_view(),
name="finance_subscription_me",
),
path(
"store/confirm/",
ConfirmStorePurchaseView.as_view(),
name="finance_store_confirm",
),
path(
"webhooks/stripe/",
StripeWebhookView.as_view(),
name="finance_stripe_webhook",
),
path(
"webhooks/revenuecat/",
RevenueCatWebhookView.as_view(),
name="finance_revenuecat_webhook",
),
]
+50 -51
View File
@@ -12,6 +12,7 @@ from monetization.serializers import (
InvoiceSerializer,
PaymentSerializer,
PortalSessionSerializer,
StorePurchaseConfirmSerializer,
SubscriptionPlanSerializer,
)
from monetization.services.plans import (
@@ -20,10 +21,13 @@ from monetization.services.plans import (
seed_subscription_plans,
)
from monetization.services.quotas import get_usage_snapshot
from monetization.services.revenuecat import (
RevenueCatWebhookAuthError,
dispatch_revenuecat_event,
verify_revenuecat_authorization,
from monetization.services.store_purchases import (
StoreNotConfiguredError,
StorePurchaseRejected,
StoreVerificationError,
confirm_store_purchase,
expire_lapsed_store_subscription,
store_account_token,
)
from monetization.services.stripe import (
StripeNotConfiguredError,
@@ -194,6 +198,7 @@ class SubscriptionMeView(APIView):
sub = request.user.subscription
except UserSubscription.DoesNotExist:
sub = None
sub = expire_lapsed_store_subscription(sub)
usage = get_usage_snapshot(request.user)
payload = {
@@ -213,11 +218,52 @@ class SubscriptionMeView(APIView):
if sub and sub.current_period_end
else None
),
"store_account_token": store_account_token(request.user.pk),
"usage": usage.to_dict(),
}
return Response(payload)
class ConfirmStorePurchaseView(APIView):
"""Verify a StoreKit or Play Billing purchase and grant the plan.
The app calls this after the native store sheet succeeds. There is no
store webhook.
"""
def post(self, request):
serializer = StorePurchaseConfirmSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
sub = confirm_store_purchase(request.user, serializer.validated_data)
except StorePurchaseRejected as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except StoreNotConfiguredError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except StoreVerificationError as exc:
logger.exception("Store purchase verification failed")
return Response({"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY)
return Response(
{
"source": sub.source,
"status": sub.status,
"plan_slug": sub.plan.slug if sub.plan_id else None,
"cancel_at_period_end": bool(sub.cancel_at_period_end),
"current_period_end": (
sub.current_period_end.isoformat()
if sub.current_period_end
else None
),
"store_account_token": store_account_token(request.user.pk),
},
status=status.HTTP_200_OK,
)
class StripeWebhookView(APIView):
"""Verify Stripe signatures and upsert local invoice/payment rows."""
@@ -266,50 +312,3 @@ class StripeWebhookView(APIView):
)
return Response({"received": True}, status=status.HTTP_200_OK)
class RevenueCatWebhookView(APIView):
"""Verify RevenueCat Authorization and upsert subscription + ledger rows."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request):
webhook_secret = settings.REVENUECAT_WEBHOOK_SECRET
if not webhook_secret:
logger.error("REVENUECAT_WEBHOOK_SECRET is not configured")
return Response(
{"detail": "Webhook secret not configured"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
try:
verify_revenuecat_authorization(
authorization_header=request.META.get("HTTP_AUTHORIZATION"),
expected_secret=webhook_secret,
)
except RevenueCatWebhookAuthError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_401_UNAUTHORIZED,
)
payload = request.data
if not isinstance(payload, dict):
return Response(
{"detail": "Invalid payload"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
dispatch_revenuecat_event(payload)
except Exception:
event = payload.get("event") if isinstance(payload, dict) else {}
event_id = event.get("id") if isinstance(event, dict) else None
logger.exception("Error handling RevenueCat event %s", event_id)
return Response(
{"detail": "Webhook handler error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"received": True}, status=status.HTTP_200_OK)