From 4fc2b87bb45bdcb9ff8a97cc435332046e6a6332 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 19 Oct 2026 04:37:49 -0700 Subject: [PATCH] Grant mobile plans from verified App Store and Play purchases (#78) (#79) ## Summary - Mobile access is granted by `POST /api/finance/store/confirm/` after the app finishes a StoreKit or Play Billing purchase. - App Store signed transactions are checked against Apple Root CA G3. Play purchase tokens are checked with the Play Developer API. - The RevenueCat webhook no longer grants a plan. Stripe Checkout and the Stripe webhook stay for the website. - A purchase account token must match the signed-in user, and a transaction already linked to another account is rejected. Access ends when the verified period end passes unless the app confirms again. Client: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/122 ## Config - `APPLE_IAP_BUNDLE_ID` (default `ai.hesychia.chat`) - `GOOGLE_PLAY_PACKAGE_NAME` - `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` (JSON or a file path) - Plan `revenuecat_product_id` or `REVENUECAT_PRODUCT_PLAN_MAP` maps the store product to a plan ## Test plan - [x] `python manage.py test monetization.tests.test_store_purchases monetization.tests.test_revenuecat monetization.tests.test_plans_quotas` - [ ] Confirm an App Store sandbox transaction and a Play license-test purchase against a configured backend - [ ] Confirm a token already linked to another user is rejected - [ ] Confirm `GET /subscription/` returns `store_account_token` and flips a lapsed store sub to canceled Reviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls/79 --- .env.example | 8 +- .env.prod.example | 4 +- README.md | 16 +- llm_be/llm_be/settings.py | 14 +- llm_be/monetization/certs/apple_root_g3.pem | 15 + .../migrations/0006_store_purchase_sources.py | 56 ++ llm_be/monetization/models.py | 6 + llm_be/monetization/serializers.py | 16 + llm_be/monetization/services/plans.py | 7 +- .../monetization/services/store_purchases.py | 617 ++++++++++++++++++ llm_be/monetization/tests/test_revenuecat.py | 52 +- .../tests/test_store_purchases.py | 336 ++++++++++ llm_be/monetization/urls.py | 12 +- llm_be/monetization/views.py | 101 ++- 14 files changed, 1141 insertions(+), 119 deletions(-) create mode 100644 llm_be/monetization/certs/apple_root_g3.pem create mode 100644 llm_be/monetization/migrations/0006_store_purchase_sources.py create mode 100644 llm_be/monetization/services/store_purchases.py create mode 100644 llm_be/monetization/tests/test_store_purchases.py diff --git a/.env.example b/.env.example index 655ec3f..a139d5f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.prod.example b/.env.prod.example index 178d66e..bd508e1 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -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). diff --git a/README.md b/README.md index e5be19b..1adb52d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/llm_be/llm_be/settings.py b/llm_be/llm_be/settings.py index 8b24397..bcf1209 100644 --- a/llm_be/llm_be/settings.py +++ b/llm_be/llm_be/settings.py @@ -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(): diff --git a/llm_be/monetization/certs/apple_root_g3.pem b/llm_be/monetization/certs/apple_root_g3.pem new file mode 100644 index 0000000..561fc80 --- /dev/null +++ b/llm_be/monetization/certs/apple_root_g3.pem @@ -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----- diff --git a/llm_be/monetization/migrations/0006_store_purchase_sources.py b/llm_be/monetization/migrations/0006_store_purchase_sources.py new file mode 100644 index 0000000..015447a --- /dev/null +++ b/llm_be/monetization/migrations/0006_store_purchase_sources.py @@ -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, + ), + ), + ] diff --git a/llm_be/monetization/models.py b/llm_be/monetization/models.py index 8f74e7b..0c487bd 100644 --- a/llm_be/monetization/models.py +++ b/llm_be/monetization/models.py @@ -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" diff --git a/llm_be/monetization/serializers.py b/llm_be/monetization/serializers.py index ad24beb..eac84b6 100644 --- a/llm_be/monetization/serializers.py +++ b/llm_be/monetization/serializers.py @@ -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 diff --git a/llm_be/monetization/services/plans.py b/llm_be/monetization/services/plans.py index 406b433..ed1af9f 100644 --- a/llm_be/monetization/services/plans.py +++ b/llm_be/monetization/services/plans.py @@ -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, diff --git a/llm_be/monetization/services/store_purchases.py b/llm_be/monetization/services/store_purchases.py new file mode 100644 index 0000000..7d2485e --- /dev/null +++ b/llm_be/monetization/services/store_purchases.py @@ -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 diff --git a/llm_be/monetization/tests/test_revenuecat.py b/llm_be/monetization/tests/test_revenuecat.py index dc197d8..878bd0d 100644 --- a/llm_be/monetization/tests/test_revenuecat.py +++ b/llm_be/monetization/tests/test_revenuecat.py @@ -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() - ) diff --git a/llm_be/monetization/tests/test_store_purchases.py b/llm_be/monetization/tests/test_store_purchases.py new file mode 100644 index 0000000..9c8ca2c --- /dev/null +++ b/llm_be/monetization/tests/test_store_purchases.py @@ -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), + ) diff --git a/llm_be/monetization/urls.py b/llm_be/monetization/urls.py index 46868cd..be247c2 100644 --- a/llm_be/monetization/urls.py +++ b/llm_be/monetization/urls.py @@ -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", - ), ] diff --git a/llm_be/monetization/views.py b/llm_be/monetization/views.py index b068a9f..78f8311 100644 --- a/llm_be/monetization/views.py +++ b/llm_be/monetization/views.py @@ -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)