Grant mobile plans from verified App Store and Play purchases (#78).
The app confirms a StoreKit signed transaction or Play purchase token. The backend checks it with Apple or Google before writing the ledger, and the RevenueCat webhook no longer grants access.
This commit is contained in:
1 parent
bb5c43a4e7
commit
74e8a4ad71
14 files changed
+1141
-119
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
|
# Optional: pre-created Stripe Price ID for Founders. When empty, Checkout uses
|
||||||
# SubscriptionPlan.price_cents / SUBSCRIPTION_PRICE_* ($10 USD / month Founders).
|
# SubscriptionPlan.price_cents / SUBSCRIPTION_PRICE_* ($10 USD / month Founders).
|
||||||
STRIPE_PRICE_ID=
|
STRIPE_PRICE_ID=
|
||||||
# RevenueCat webhook Authorization bearer secret (store IAP).
|
# Native store purchases (#78). iOS verifies the StoreKit signed transaction
|
||||||
REVENUECAT_WEBHOOK_SECRET=
|
# 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.
|
# Optional JSON map of store product id → plan slug, e.g.
|
||||||
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
||||||
# SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000
|
# 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_SECRET_KEY=replace-with-stripe-secret-key
|
||||||
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
||||||
STRIPE_WEBHOOK_SECRET=replace-with-stripe-webhook-secret
|
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"}
|
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
||||||
# Optional: pre-created Stripe Price ID. When empty, Checkout uses
|
# Optional: pre-created Stripe Price ID. When empty, Checkout uses
|
||||||
# SUBSCRIPTION_PRICE_* from settings.py ($10 USD / month by default).
|
# 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 | |
|
| `CAPTCHA_SECRET_KEY` | empty | recommended | |
|
||||||
| `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
(`POST /api/finance/portal/`). Local state syncs via
|
||||||
`customer.subscription.updated` / `deleted` webhooks.
|
`customer.subscription.updated` / `deleted` webhooks.
|
||||||
|
|
||||||
**Native (Play / App Store):** RevenueCat webhooks at
|
**Native (Play / App Store):** the app buys through StoreKit or Play Billing,
|
||||||
`POST /api/finance/webhooks/revenuecat/` (Authorization bearer =
|
then `POST /api/finance/store/confirm/` with the signed App Store transaction
|
||||||
`REVENUECAT_WEBHOOK_SECRET`) upsert Invoice/Payment ledger rows and sync
|
or the Play purchase token. The backend verifies it (Apple certificate chain,
|
||||||
`UserSubscription` (`source=revenuecat`).
|
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
|
`GET /api/finance/subscription/` includes `cancel_at_period_end` and
|
||||||
`current_period_end` for Account UI messaging.
|
`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.
|
# price_data built from SUBSCRIPTION_PRICE_* below.
|
||||||
STRIPE_PRICE_ID = env("STRIPE_PRICE_ID", "") or ""
|
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 ""
|
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 ""
|
_rc_product_map_raw = env("REVENUECAT_PRODUCT_PLAN_MAP", "") or ""
|
||||||
REVENUECAT_PRODUCT_PLAN_MAP: dict = {}
|
REVENUECAT_PRODUCT_PLAN_MAP: dict = {}
|
||||||
if _rc_product_map_raw.strip():
|
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"
|
NONE = "none", "None"
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
REVENUECAT = "revenuecat", "RevenueCat"
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
|
APP_STORE = "app_store", "App Store"
|
||||||
|
PLAY_STORE = "play_store", "Play Store"
|
||||||
BACKER = "backer", "Backer"
|
BACKER = "backer", "Backer"
|
||||||
ADMIN = "admin", "Admin"
|
ADMIN = "admin", "Admin"
|
||||||
|
|
||||||
@@ -214,6 +216,8 @@ class Invoice(TimeInfoBase):
|
|||||||
class Provider(models.TextChoices):
|
class Provider(models.TextChoices):
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
REVENUECAT = "revenuecat", "RevenueCat"
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
|
APP_STORE = "app_store", "App Store"
|
||||||
|
PLAY_STORE = "play_store", "Play Store"
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
DRAFT = "draft", "Draft"
|
DRAFT = "draft", "Draft"
|
||||||
@@ -308,6 +312,8 @@ class Payment(TimeInfoBase):
|
|||||||
class Provider(models.TextChoices):
|
class Provider(models.TextChoices):
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
REVENUECAT = "revenuecat", "RevenueCat"
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
|
APP_STORE = "app_store", "App Store"
|
||||||
|
PLAY_STORE = "play_store", "Play Store"
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
PENDING = "pending", "Pending"
|
PENDING = "pending", "Pending"
|
||||||
|
|||||||
@@ -59,8 +59,23 @@ class PortalSessionSerializer(serializers.Serializer):
|
|||||||
return_url = serializers.URLField(required=False, allow_blank=False)
|
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):
|
class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
||||||
features = serializers.SerializerMethodField()
|
features = serializers.SerializerMethodField()
|
||||||
|
store_product_id = serializers.CharField(
|
||||||
|
source="revenuecat_product_id", read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SubscriptionPlan
|
model = SubscriptionPlan
|
||||||
@@ -78,6 +93,7 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
|||||||
"prompt_window_hours",
|
"prompt_window_hours",
|
||||||
"monthly_token_quota",
|
"monthly_token_quota",
|
||||||
"sort_order",
|
"sort_order",
|
||||||
|
"store_product_id",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,8 @@ def needs_checkout(user) -> bool:
|
|||||||
UserSubscription.Source.ADMIN,
|
UserSubscription.Source.ADMIN,
|
||||||
UserSubscription.Source.STRIPE,
|
UserSubscription.Source.STRIPE,
|
||||||
UserSubscription.Source.REVENUECAT,
|
UserSubscription.Source.REVENUECAT,
|
||||||
|
UserSubscription.Source.APP_STORE,
|
||||||
|
UserSubscription.Source.PLAY_STORE,
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -457,8 +459,9 @@ def assign_plan_from_revenuecat(
|
|||||||
cancel_at_period_end: bool | None = None,
|
cancel_at_period_end: bool | None = None,
|
||||||
current_period_end=None,
|
current_period_end=None,
|
||||||
keep_existing_plan_if_unknown: bool = False,
|
keep_existing_plan_if_unknown: bool = False,
|
||||||
|
source: str | None = None,
|
||||||
) -> UserSubscription:
|
) -> 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)
|
seed_subscription_plans(update_existing=False)
|
||||||
existing = (
|
existing = (
|
||||||
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
||||||
@@ -493,7 +496,7 @@ def assign_plan_from_revenuecat(
|
|||||||
sub = assign_plan(
|
sub = assign_plan(
|
||||||
user,
|
user,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
source=UserSubscription.Source.REVENUECAT,
|
source=source or UserSubscription.Source.REVENUECAT,
|
||||||
status=status,
|
status=status,
|
||||||
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
|
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
|
||||||
log_auth_event=False,
|
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 __future__ import annotations
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from chat_backend.tests.factories import make_user
|
from chat_backend.tests.factories import make_user
|
||||||
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
||||||
@@ -129,50 +126,3 @@ class RevenueCatDispatchTests(TestCase):
|
|||||||
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
|
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 django.urls import path
|
||||||
|
|
||||||
from monetization.views import (
|
from monetization.views import (
|
||||||
|
ConfirmStorePurchaseView,
|
||||||
CreateBillingPortalSessionView,
|
CreateBillingPortalSessionView,
|
||||||
CreateCheckoutSessionView,
|
CreateCheckoutSessionView,
|
||||||
InvoiceListView,
|
InvoiceListView,
|
||||||
PaymentListView,
|
PaymentListView,
|
||||||
PlanListView,
|
PlanListView,
|
||||||
RevenueCatWebhookView,
|
|
||||||
StripeWebhookView,
|
StripeWebhookView,
|
||||||
SubscriptionMeView,
|
SubscriptionMeView,
|
||||||
)
|
)
|
||||||
@@ -42,14 +42,14 @@ urlpatterns = [
|
|||||||
SubscriptionMeView.as_view(),
|
SubscriptionMeView.as_view(),
|
||||||
name="finance_subscription_me",
|
name="finance_subscription_me",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"store/confirm/",
|
||||||
|
ConfirmStorePurchaseView.as_view(),
|
||||||
|
name="finance_store_confirm",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"webhooks/stripe/",
|
"webhooks/stripe/",
|
||||||
StripeWebhookView.as_view(),
|
StripeWebhookView.as_view(),
|
||||||
name="finance_stripe_webhook",
|
name="finance_stripe_webhook",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"webhooks/revenuecat/",
|
|
||||||
RevenueCatWebhookView.as_view(),
|
|
||||||
name="finance_revenuecat_webhook",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
@@ -12,6 +12,7 @@ from monetization.serializers import (
|
|||||||
InvoiceSerializer,
|
InvoiceSerializer,
|
||||||
PaymentSerializer,
|
PaymentSerializer,
|
||||||
PortalSessionSerializer,
|
PortalSessionSerializer,
|
||||||
|
StorePurchaseConfirmSerializer,
|
||||||
SubscriptionPlanSerializer,
|
SubscriptionPlanSerializer,
|
||||||
)
|
)
|
||||||
from monetization.services.plans import (
|
from monetization.services.plans import (
|
||||||
@@ -20,10 +21,13 @@ from monetization.services.plans import (
|
|||||||
seed_subscription_plans,
|
seed_subscription_plans,
|
||||||
)
|
)
|
||||||
from monetization.services.quotas import get_usage_snapshot
|
from monetization.services.quotas import get_usage_snapshot
|
||||||
from monetization.services.revenuecat import (
|
from monetization.services.store_purchases import (
|
||||||
RevenueCatWebhookAuthError,
|
StoreNotConfiguredError,
|
||||||
dispatch_revenuecat_event,
|
StorePurchaseRejected,
|
||||||
verify_revenuecat_authorization,
|
StoreVerificationError,
|
||||||
|
confirm_store_purchase,
|
||||||
|
expire_lapsed_store_subscription,
|
||||||
|
store_account_token,
|
||||||
)
|
)
|
||||||
from monetization.services.stripe import (
|
from monetization.services.stripe import (
|
||||||
StripeNotConfiguredError,
|
StripeNotConfiguredError,
|
||||||
@@ -194,6 +198,7 @@ class SubscriptionMeView(APIView):
|
|||||||
sub = request.user.subscription
|
sub = request.user.subscription
|
||||||
except UserSubscription.DoesNotExist:
|
except UserSubscription.DoesNotExist:
|
||||||
sub = None
|
sub = None
|
||||||
|
sub = expire_lapsed_store_subscription(sub)
|
||||||
|
|
||||||
usage = get_usage_snapshot(request.user)
|
usage = get_usage_snapshot(request.user)
|
||||||
payload = {
|
payload = {
|
||||||
@@ -213,11 +218,52 @@ class SubscriptionMeView(APIView):
|
|||||||
if sub and sub.current_period_end
|
if sub and sub.current_period_end
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"store_account_token": store_account_token(request.user.pk),
|
||||||
"usage": usage.to_dict(),
|
"usage": usage.to_dict(),
|
||||||
}
|
}
|
||||||
return Response(payload)
|
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):
|
class StripeWebhookView(APIView):
|
||||||
"""Verify Stripe signatures and upsert local invoice/payment rows."""
|
"""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)
|
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