Grant mobile plans from verified App Store and Play purchases (#78) #79
No files matched your search
+6
-2
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user