Add monetization app with RevenueCat webhooks alongside Stripe.
Rename finance → monetization (keep finance_* tables via app label), add RevenueCat webhook + ledger upserts so store IAP syncs subscriptions and billing history like Stripe. Companion to chat_web_app#100 / #68.
This commit is contained in:
+5
-1
@@ -76,13 +76,17 @@ OAUTH_CALLBACK_BASE_URL=http://127.0.0.1:8001
|
|||||||
# POST {OAUTH_CALLBACK_BASE_URL}/api/drive/webhooks/microsoft/
|
# POST {OAUTH_CALLBACK_BASE_URL}/api/drive/webhooks/microsoft/
|
||||||
# Worker sync: `python manage.py sync_drive_connections [--connection-id N]`
|
# Worker sync: `python manage.py sync_drive_connections [--connection-id N]`
|
||||||
|
|
||||||
# Stripe / finance (optional local — required for checkout + webhooks)
|
# Stripe / monetization (optional local — required for checkout + webhooks)
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_PUBLISHABLE_KEY=
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
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).
|
||||||
|
REVENUECAT_WEBHOOK_SECRET=
|
||||||
|
# Optional JSON map of store product id → plan slug, e.g.
|
||||||
|
# REVENUECAT_PRODUCT_PLAN_MAP={"hesychia_founders_monthly":"founders"}
|
||||||
# SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000
|
# SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000
|
||||||
# SUBSCRIPTION_PRICE_CURRENCY=usd
|
# SUBSCRIPTION_PRICE_CURRENCY=usd
|
||||||
# SUBSCRIPTION_PRICE_INTERVAL=month
|
# SUBSCRIPTION_PRICE_INTERVAL=month
|
||||||
|
|||||||
+3
-1
@@ -87,10 +87,12 @@ OAUTH_CALLBACK_BASE_URL=https://chatbackend.aimloperations.com
|
|||||||
# https://chatbackend.aimloperations.com/api/drive/webhooks/microsoft/
|
# https://chatbackend.aimloperations.com/api/drive/webhooks/microsoft/
|
||||||
# Scheduled sync (cron / server-infra job): `python manage.py sync_drive_connections`
|
# Scheduled sync (cron / server-infra job): `python manage.py sync_drive_connections`
|
||||||
|
|
||||||
# Stripe / finance
|
# Stripe / monetization
|
||||||
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
|
||||||
|
# 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).
|
||||||
STRIPE_PRICE_ID=
|
STRIPE_PRICE_ID=
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ with `COMPOSE_DATABASE_URL` if needed.
|
|||||||
| `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO |
|
| `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO |
|
||||||
| `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 |
|
| `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 |
|
||||||
|
| `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 |
|
||||||
| `MICROSOFT_OAUTH_CLIENT_ID` / `..._SECRET` / `..._TENANT` | empty / `common` | for SSO/Drive | Also used for Drive linking (#47), incremental scopes |
|
| `MICROSOFT_OAUTH_CLIENT_ID` / `..._SECRET` / `..._TENANT` | empty / `common` | for SSO/Drive | Also used for Drive linking (#47), incremental scopes |
|
||||||
@@ -276,9 +278,19 @@ Post-delete UX: clear local tokens → redirect to sign-in. Subsequent
|
|||||||
|
|
||||||
### Subscription change / cancel (portal + webhooks)
|
### Subscription change / cancel (portal + webhooks)
|
||||||
|
|
||||||
Plan change and cancel stay on Stripe Customer Portal
|
Billing lives in the **`monetization`** Django app (package rename of
|
||||||
|
`finance`; DB tables keep the `finance_*` prefix via app `label = "finance"`).
|
||||||
|
URLs: `/api/monetization/...` and alias `/api/finance/...`.
|
||||||
|
|
||||||
|
**Web:** plan change/cancel stay on Stripe Customer Portal
|
||||||
(`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
|
||||||
|
`POST /api/finance/webhooks/revenuecat/` (Authorization bearer =
|
||||||
|
`REVENUECAT_WEBHOOK_SECRET`) upsert Invoice/Payment ledger rows and sync
|
||||||
|
`UserSubscription` (`source=revenuecat`).
|
||||||
|
|
||||||
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ from .utils import (
|
|||||||
is_heartbeat_payload,
|
is_heartbeat_payload,
|
||||||
normalize_user_message,
|
normalize_user_message,
|
||||||
)
|
)
|
||||||
from finance.services.quotas import (
|
from monetization.services.quotas import (
|
||||||
FeatureNotAllowed,
|
FeatureNotAllowed,
|
||||||
QuotaExceeded,
|
QuotaExceeded,
|
||||||
check_generation_allowed,
|
check_generation_allowed,
|
||||||
@@ -88,7 +88,7 @@ def enforce_generation_gates(user, feature="text_generation"):
|
|||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def enforce_feature_gate(user, feature):
|
def enforce_feature_gate(user, feature):
|
||||||
from finance.services.quotas import assert_feature_allowed
|
from monetization.services.quotas import assert_feature_allowed
|
||||||
|
|
||||||
assert_feature_allowed(user, feature)
|
assert_feature_allowed(user, feature)
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from .utils import (
|
|||||||
is_heartbeat_payload,
|
is_heartbeat_payload,
|
||||||
normalize_user_message,
|
normalize_user_message,
|
||||||
)
|
)
|
||||||
from finance.services.quotas import FeatureNotAllowed, QuotaExceeded, check_generation_allowed
|
from monetization.services.quotas import FeatureNotAllowed, QuotaExceeded, check_generation_allowed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ def enforce_generation_gates(user, feature="text_generation"):
|
|||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def enforce_feature_gate(user, feature):
|
def enforce_feature_gate(user, feature):
|
||||||
from finance.services.quotas import assert_feature_allowed
|
from monetization.services.quotas import assert_feature_allowed
|
||||||
|
|
||||||
assert_feature_allowed(user, feature)
|
assert_feature_allowed(user, feature)
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ def upsert_drive_connection(
|
|||||||
|
|
||||||
|
|
||||||
def _create_sso_user(profile: ProviderProfile) -> CustomUser:
|
def _create_sso_user(profile: ProviderProfile) -> CustomUser:
|
||||||
from finance.services.plans import try_redeem_backer_email
|
from monetization.services.plans import try_redeem_backer_email
|
||||||
|
|
||||||
company = Company.objects.create(
|
company = Company.objects.create(
|
||||||
name=f"{profile.email}'s workspace",
|
name=f"{profile.email}'s workspace",
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
|||||||
extra_kwargs = {"password": {"write_only": True}}
|
extra_kwargs = {"password": {"write_only": True}}
|
||||||
|
|
||||||
def get_subscription(self, obj):
|
def get_subscription(self, obj):
|
||||||
from finance.services.plans import needs_checkout, plan_to_dict
|
from monetization.services.plans import needs_checkout, plan_to_dict
|
||||||
from finance.models import UserSubscription
|
from monetization.models import UserSubscription
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sub = obj.subscription
|
sub = obj.subscription
|
||||||
@@ -121,7 +121,7 @@ class SelfServeRegistrationSerializer(serializers.Serializer):
|
|||||||
return email
|
return email
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
from finance.services.plans import try_redeem_backer_email
|
from monetization.services.plans import try_redeem_backer_email
|
||||||
|
|
||||||
email = validated_data["email"]
|
email = validated_data["email"]
|
||||||
password = validated_data["password"]
|
password = validated_data["password"]
|
||||||
|
|||||||
@@ -382,8 +382,8 @@ class GraphNodeTestCase(TransactionTestCase):
|
|||||||
|
|
||||||
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
||||||
async def test_generation_node_denies_rag_on_standard_plan(self):
|
async def test_generation_node_denies_rag_on_standard_plan(self):
|
||||||
from finance.models import SubscriptionPlan, UserSubscription
|
from monetization.models import SubscriptionPlan, UserSubscription
|
||||||
from finance.services.plans import assign_plan, seed_subscription_plans
|
from monetization.services.plans import assign_plan, seed_subscription_plans
|
||||||
|
|
||||||
await sync_to_async(seed_subscription_plans)()
|
await sync_to_async(seed_subscription_plans)()
|
||||||
standard = await sync_to_async(SubscriptionPlan.objects.get)(slug="standard")
|
standard = await sync_to_async(SubscriptionPlan.objects.get)(slug="standard")
|
||||||
@@ -403,8 +403,8 @@ class GraphNodeTestCase(TransactionTestCase):
|
|||||||
|
|
||||||
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
||||||
async def test_generation_node_allows_rag_with_founders_plan(self):
|
async def test_generation_node_allows_rag_with_founders_plan(self):
|
||||||
from finance.models import SubscriptionPlan, UserSubscription
|
from monetization.models import SubscriptionPlan, UserSubscription
|
||||||
from finance.services.plans import assign_plan, seed_subscription_plans
|
from monetization.services.plans import assign_plan, seed_subscription_plans
|
||||||
|
|
||||||
await sync_to_async(seed_subscription_plans)()
|
await sync_to_async(seed_subscription_plans)()
|
||||||
founders = await sync_to_async(SubscriptionPlan.objects.get)(slug="founders")
|
founders = await sync_to_async(SubscriptionPlan.objects.get)(slug="founders")
|
||||||
|
|||||||
@@ -305,8 +305,8 @@ class OAuthStartDriveLinkTestCase(APITestCase):
|
|||||||
|
|
||||||
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
||||||
def test_link_drive_denied_when_plan_disallows_rag(self):
|
def test_link_drive_denied_when_plan_disallows_rag(self):
|
||||||
from finance.services.plans import assign_plan, seed_subscription_plans
|
from monetization.services.plans import assign_plan, seed_subscription_plans
|
||||||
from finance.models import SubscriptionPlan, UserSubscription
|
from monetization.models import SubscriptionPlan, UserSubscription
|
||||||
|
|
||||||
seed_subscription_plans()
|
seed_subscription_plans()
|
||||||
standard = SubscriptionPlan.objects.get(slug="standard")
|
standard = SubscriptionPlan.objects.get(slug="standard")
|
||||||
@@ -493,8 +493,8 @@ class OAuthCallbackDriveLinkTestCase(APITestCase):
|
|||||||
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
|
||||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||||
def test_callback_denied_when_plan_disallows_rag(self, mock_exchange):
|
def test_callback_denied_when_plan_disallows_rag(self, mock_exchange):
|
||||||
from finance.services.plans import assign_plan, seed_subscription_plans
|
from monetization.services.plans import assign_plan, seed_subscription_plans
|
||||||
from finance.models import SubscriptionPlan, UserSubscription
|
from monetization.models import SubscriptionPlan, UserSubscription
|
||||||
|
|
||||||
seed_subscription_plans()
|
seed_subscription_plans()
|
||||||
standard = SubscriptionPlan.objects.get(slug="standard")
|
standard = SubscriptionPlan.objects.get(slug="standard")
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from chat_backend.models import Document, DocumentWorkspace, StoredFile
|
from chat_backend.models import Document, DocumentWorkspace, StoredFile
|
||||||
from finance.models import SubscriptionPlan, UserSubscription
|
from monetization.models import SubscriptionPlan, UserSubscription
|
||||||
from finance.services.plans import assign_plan, seed_subscription_plans
|
from monetization.services.plans import assign_plan, seed_subscription_plans
|
||||||
|
|
||||||
from .factories import (
|
from .factories import (
|
||||||
make_company,
|
make_company,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from chat_backend.models import DriveConnection
|
from chat_backend.models import DriveConnection
|
||||||
from finance.models import SubscriptionPlan, UserSubscription
|
from monetization.models import SubscriptionPlan, UserSubscription
|
||||||
from finance.services.plans import assign_plan, seed_subscription_plans
|
from monetization.services.plans import assign_plan, seed_subscription_plans
|
||||||
|
|
||||||
from .factories import make_company, make_drive_connection, make_user
|
from .factories import make_company, make_drive_connection, make_user
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ from .email_tasks import (
|
|||||||
send_invite_email,
|
send_invite_email,
|
||||||
send_password_reset_email,
|
send_password_reset_email,
|
||||||
)
|
)
|
||||||
from finance.services.quotas import FeatureNotAllowed, assert_feature_allowed
|
from monetization.services.quotas import FeatureNotAllowed, assert_feature_allowed
|
||||||
from .services.llm_service import AsyncLLMService
|
from .services.llm_service import AsyncLLMService
|
||||||
from .services.rag_services import AsyncRAGService
|
from .services.rag_services import AsyncRAGService
|
||||||
from .services.chat_tenant_scope import (
|
from .services.chat_tenant_scope import (
|
||||||
@@ -143,7 +143,7 @@ class CustomUserCreate(APIView):
|
|||||||
|
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
from finance.services.plans import needs_checkout
|
from monetization.services.plans import needs_checkout
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rest_framework import permissions, status
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from finance.services.quotas import FeatureNotAllowed, assert_feature_allowed
|
from monetization.services.quotas import FeatureNotAllowed, assert_feature_allowed
|
||||||
|
|
||||||
from .drive_tasks import enqueue_drive_sync
|
from .drive_tasks import enqueue_drive_sync
|
||||||
from .models import DriveConnection
|
from .models import DriveConnection
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from rest_framework.views import APIView
|
|||||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
from finance.services.quotas import FeatureNotAllowed, assert_feature_allowed
|
from monetization.services.quotas import FeatureNotAllowed, assert_feature_allowed
|
||||||
|
|
||||||
from .models import DriveConnection, OAuthIdentity
|
from .models import DriveConnection, OAuthIdentity
|
||||||
from .oauth import (
|
from .oauth import (
|
||||||
@@ -201,7 +201,7 @@ class OAuthCallbackView(APIView):
|
|||||||
return _redirect_error("server_error", "Unexpected OAuth error.")
|
return _redirect_error("server_error", "Unexpected OAuth error.")
|
||||||
|
|
||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
from finance.services.plans import needs_checkout as user_needs_checkout
|
from monetization.services.plans import needs_checkout as user_needs_checkout
|
||||||
|
|
||||||
needs_checkout = "1" if (created and user_needs_checkout(user)) else "0"
|
needs_checkout = "1" if (created and user_needs_checkout(user)) else "0"
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class FinanceConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "finance"
|
|
||||||
verbose_name = "Finance"
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
from django.db.models.signals import post_migrate
|
|
||||||
|
|
||||||
from finance.signals import seed_plans_on_migrate
|
|
||||||
|
|
||||||
post_migrate.connect(seed_plans_on_migrate, sender=self)
|
|
||||||
@@ -181,7 +181,7 @@ INSTALLED_APPS = [
|
|||||||
"whitenoise.runserver_nostatic",
|
"whitenoise.runserver_nostatic",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"chat_backend",
|
"chat_backend",
|
||||||
"finance.apps.FinanceConfig",
|
"monetization.apps.MonetizationConfig",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"rest_framework_simplejwt.token_blacklist",
|
"rest_framework_simplejwt.token_blacklist",
|
||||||
@@ -346,7 +346,7 @@ MICROSOFT_OAUTH_TENANT = env("MICROSOFT_OAUTH_TENANT", "common") or "common"
|
|||||||
OAUTH_CALLBACK_BASE_URL = (env("OAUTH_CALLBACK_BASE_URL", "") or "").rstrip("/")
|
OAUTH_CALLBACK_BASE_URL = (env("OAUTH_CALLBACK_BASE_URL", "") or "").rstrip("/")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Finance / Stripe (subscription billing)
|
# Monetization / Stripe + RevenueCat (subscription billing)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "") or ""
|
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "") or ""
|
||||||
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "") or ""
|
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "") or ""
|
||||||
@@ -355,6 +355,22 @@ 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.
|
||||||
|
REVENUECAT_WEBHOOK_SECRET = env("REVENUECAT_WEBHOOK_SECRET", "") or ""
|
||||||
|
_rc_product_map_raw = env("REVENUECAT_PRODUCT_PLAN_MAP", "") or ""
|
||||||
|
REVENUECAT_PRODUCT_PLAN_MAP: dict = {}
|
||||||
|
if _rc_product_map_raw.strip():
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = _json.loads(_rc_product_map_raw)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
REVENUECAT_PRODUCT_PLAN_MAP = {
|
||||||
|
str(k): str(v) for k, v in parsed.items()
|
||||||
|
}
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
REVENUECAT_PRODUCT_PLAN_MAP = {}
|
||||||
|
|
||||||
# Subscription list price — $10.00 USD / month (amount in cents).
|
# Subscription list price — $10.00 USD / month (amount in cents).
|
||||||
SUBSCRIPTION_PRICE_AMOUNT_CENTS = int(
|
SUBSCRIPTION_PRICE_AMOUNT_CENTS = int(
|
||||||
env("SUBSCRIPTION_PRICE_AMOUNT_CENTS", "1000") or "1000"
|
env("SUBSCRIPTION_PRICE_AMOUNT_CENTS", "1000") or "1000"
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ urlpatterns = (
|
|||||||
[
|
[
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/", include("chat_backend.urls")),
|
path("api/", include("chat_backend.urls")),
|
||||||
path("api/finance/", include("finance.urls")),
|
path("api/finance/", include("monetization.urls")), # alias
|
||||||
|
path("api/monetization/", include("monetization.urls")),
|
||||||
]
|
]
|
||||||
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from finance.models import BackerEmail, Invoice, Payment, SubscriptionPlan, UserSubscription
|
from monetization.models import BackerEmail, Invoice, Payment, SubscriptionPlan, UserSubscription
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SubscriptionPlan)
|
@admin.register(SubscriptionPlan)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MonetizationConfig(AppConfig):
|
||||||
|
"""Paid access: plans, quotas, Stripe, RevenueCat.
|
||||||
|
|
||||||
|
``label`` stays ``finance`` so existing ``finance_*`` tables and
|
||||||
|
``django_migrations`` / contenttypes rows keep working after the package
|
||||||
|
rename from ``finance`` → ``monetization``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "monetization"
|
||||||
|
label = "finance"
|
||||||
|
verbose_name = "Monetization"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from django.db.models.signals import post_migrate
|
||||||
|
|
||||||
|
from monetization.signals import seed_plans_on_migrate
|
||||||
|
|
||||||
|
post_migrate.connect(seed_plans_on_migrate, sender=self)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-08-03 19:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('finance', '0004_subscriptionplan_allows_rag'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='revenuecat_event_id',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='RevenueCat webhook event id (idempotency key).', max_length=255, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='revenuecat_store',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='APP_STORE / PLAY_STORE / etc.', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='revenuecat_transaction_id',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Store transaction id from RevenueCat.', max_length=255, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscriptionplan',
|
||||||
|
name='revenuecat_product_id',
|
||||||
|
field=models.CharField(blank=True, db_index=True, default='', help_text='Store/RevenueCat product identifier (Play + App Store).', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='usersubscription',
|
||||||
|
name='revenuecat_original_transaction_id',
|
||||||
|
field=models.CharField(blank=True, db_index=True, default='', help_text='Store original transaction id from RevenueCat events.', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='provider',
|
||||||
|
field=models.CharField(choices=[('stripe', 'Stripe'), ('revenuecat', 'RevenueCat')], default='stripe', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='provider',
|
||||||
|
field=models.CharField(choices=[('stripe', 'Stripe'), ('revenuecat', 'RevenueCat')], default='stripe', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='usersubscription',
|
||||||
|
name='cancel_at_period_end',
|
||||||
|
field=models.BooleanField(default=False, help_text='Subscription will cancel at current_period_end.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='usersubscription',
|
||||||
|
name='current_period_end',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Billing period end (access remains until then when canceling).', null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='usersubscription',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(choices=[('none', 'None'), ('stripe', 'Stripe'), ('revenuecat', 'RevenueCat'), ('backer', 'Backer'), ('admin', 'Admin')], default='none', max_length=32),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -30,6 +30,13 @@ class SubscriptionPlan(TimeInfoBase):
|
|||||||
default="",
|
default="",
|
||||||
help_text="Optional Stripe Price id; empty uses price_data at Checkout.",
|
help_text="Optional Stripe Price id; empty uses price_data at Checkout.",
|
||||||
)
|
)
|
||||||
|
revenuecat_product_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Store/RevenueCat product identifier (Play + App Store).",
|
||||||
|
)
|
||||||
is_public = models.BooleanField(
|
is_public = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text="Shown in public pricing / plan list APIs.",
|
help_text="Shown in public pricing / plan list APIs.",
|
||||||
@@ -112,7 +119,7 @@ class BackerEmail(TimeInfoBase):
|
|||||||
|
|
||||||
|
|
||||||
class UserSubscription(TimeInfoBase):
|
class UserSubscription(TimeInfoBase):
|
||||||
"""Per-user plan assignment (Stripe, Backer whitelist, or admin)."""
|
"""Per-user plan assignment (Stripe, RevenueCat, Backer, or admin)."""
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
NONE = "none", "None"
|
NONE = "none", "None"
|
||||||
@@ -123,6 +130,7 @@ class UserSubscription(TimeInfoBase):
|
|||||||
class Source(models.TextChoices):
|
class Source(models.TextChoices):
|
||||||
NONE = "none", "None"
|
NONE = "none", "None"
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
BACKER = "backer", "Backer"
|
BACKER = "backer", "Backer"
|
||||||
ADMIN = "admin", "Admin"
|
ADMIN = "admin", "Admin"
|
||||||
|
|
||||||
@@ -155,14 +163,21 @@ class UserSubscription(TimeInfoBase):
|
|||||||
default="",
|
default="",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
|
revenuecat_original_transaction_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
db_index=True,
|
||||||
|
help_text="Store original transaction id from RevenueCat events.",
|
||||||
|
)
|
||||||
cancel_at_period_end = models.BooleanField(
|
cancel_at_period_end = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text="Stripe: subscription will cancel at current_period_end.",
|
help_text="Subscription will cancel at current_period_end.",
|
||||||
)
|
)
|
||||||
current_period_end = models.DateTimeField(
|
current_period_end = models.DateTimeField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Stripe billing period end (access remains until then when canceling).",
|
help_text="Billing period end (access remains until then when canceling).",
|
||||||
)
|
)
|
||||||
monthly_token_quota_override = models.PositiveIntegerField(
|
monthly_token_quota_override = models.PositiveIntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
@@ -191,10 +206,11 @@ class UserSubscription(TimeInfoBase):
|
|||||||
|
|
||||||
|
|
||||||
class Invoice(TimeInfoBase):
|
class Invoice(TimeInfoBase):
|
||||||
"""Local ledger row for a billed period / Stripe invoice or checkout session."""
|
"""Local ledger row for a billed period (Stripe or RevenueCat/store)."""
|
||||||
|
|
||||||
class Provider(models.TextChoices):
|
class Provider(models.TextChoices):
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
DRAFT = "draft", "Draft"
|
DRAFT = "draft", "Draft"
|
||||||
@@ -259,6 +275,20 @@ class Invoice(TimeInfoBase):
|
|||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
stripe_customer_id = models.CharField(max_length=255, blank=True, default="")
|
stripe_customer_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
revenuecat_event_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="RevenueCat webhook event id (idempotency key).",
|
||||||
|
)
|
||||||
|
revenuecat_store = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="APP_STORE / PLAY_STORE / etc.",
|
||||||
|
)
|
||||||
hosted_invoice_url = models.URLField(blank=True, default="")
|
hosted_invoice_url = models.URLField(blank=True, default="")
|
||||||
description = models.CharField(max_length=512, blank=True, default="")
|
description = models.CharField(max_length=512, blank=True, default="")
|
||||||
|
|
||||||
@@ -270,10 +300,11 @@ class Invoice(TimeInfoBase):
|
|||||||
|
|
||||||
|
|
||||||
class Payment(TimeInfoBase):
|
class Payment(TimeInfoBase):
|
||||||
"""Local ledger row for a payment attempt / Stripe PaymentIntent or charge."""
|
"""Local ledger row for a payment attempt (Stripe or store/RevenueCat)."""
|
||||||
|
|
||||||
class Provider(models.TextChoices):
|
class Provider(models.TextChoices):
|
||||||
STRIPE = "stripe", "Stripe"
|
STRIPE = "stripe", "Stripe"
|
||||||
|
REVENUECAT = "revenuecat", "RevenueCat"
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
PENDING = "pending", "Pending"
|
PENDING = "pending", "Pending"
|
||||||
@@ -331,6 +362,14 @@ class Payment(TimeInfoBase):
|
|||||||
unique=True,
|
unique=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
|
revenuecat_transaction_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Store transaction id from RevenueCat.",
|
||||||
|
)
|
||||||
paid_at = models.DateTimeField(null=True, blank=True)
|
paid_at = models.DateTimeField(null=True, blank=True)
|
||||||
failure_message = models.CharField(max_length=512, blank=True, default="")
|
failure_message = models.CharField(max_length=512, blank=True, default="")
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from finance.models import Invoice, Payment, SubscriptionPlan
|
from monetization.models import Invoice, Payment, SubscriptionPlan
|
||||||
|
|
||||||
|
|
||||||
class InvoiceSerializer(serializers.ModelSerializer):
|
class InvoiceSerializer(serializers.ModelSerializer):
|
||||||
@@ -18,6 +18,8 @@ class InvoiceSerializer(serializers.ModelSerializer):
|
|||||||
"stripe_invoice_id",
|
"stripe_invoice_id",
|
||||||
"stripe_checkout_session_id",
|
"stripe_checkout_session_id",
|
||||||
"stripe_subscription_id",
|
"stripe_subscription_id",
|
||||||
|
"revenuecat_event_id",
|
||||||
|
"revenuecat_store",
|
||||||
"hosted_invoice_url",
|
"hosted_invoice_url",
|
||||||
"description",
|
"description",
|
||||||
"created",
|
"created",
|
||||||
@@ -38,6 +40,7 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
"amount",
|
"amount",
|
||||||
"stripe_payment_intent_id",
|
"stripe_payment_intent_id",
|
||||||
"stripe_charge_id",
|
"stripe_charge_id",
|
||||||
|
"revenuecat_transaction_id",
|
||||||
"paid_at",
|
"paid_at",
|
||||||
"failure_message",
|
"failure_message",
|
||||||
"created",
|
"created",
|
||||||
@@ -5,11 +5,12 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from chat_backend.models import UserAuthEvent
|
from chat_backend.models import UserAuthEvent
|
||||||
from finance.models import BackerEmail, SubscriptionPlan, UserSubscription
|
from monetization.models import BackerEmail, SubscriptionPlan, UserSubscription
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ def assign_plan(
|
|||||||
source: str,
|
source: str,
|
||||||
status: str = UserSubscription.Status.ACTIVE,
|
status: str = UserSubscription.Status.ACTIVE,
|
||||||
stripe_subscription_id: str = "",
|
stripe_subscription_id: str = "",
|
||||||
|
revenuecat_original_transaction_id: str = "",
|
||||||
log_auth_event: bool = True,
|
log_auth_event: bool = True,
|
||||||
) -> UserSubscription:
|
) -> UserSubscription:
|
||||||
sub = get_or_create_user_subscription(user)
|
sub = get_or_create_user_subscription(user)
|
||||||
@@ -169,6 +171,7 @@ def assign_plan(
|
|||||||
prev_status = sub.status
|
prev_status = sub.status
|
||||||
prev_source = sub.source
|
prev_source = sub.source
|
||||||
prev_stripe_sub = sub.stripe_subscription_id or ""
|
prev_stripe_sub = sub.stripe_subscription_id or ""
|
||||||
|
prev_rc_txn = sub.revenuecat_original_transaction_id or ""
|
||||||
had_active = (
|
had_active = (
|
||||||
prev_status == UserSubscription.Status.ACTIVE and prev_plan_id is not None
|
prev_status == UserSubscription.Status.ACTIVE and prev_plan_id is not None
|
||||||
)
|
)
|
||||||
@@ -178,6 +181,8 @@ def assign_plan(
|
|||||||
sub.status = status
|
sub.status = status
|
||||||
if stripe_subscription_id:
|
if stripe_subscription_id:
|
||||||
sub.stripe_subscription_id = stripe_subscription_id
|
sub.stripe_subscription_id = stripe_subscription_id
|
||||||
|
if revenuecat_original_transaction_id:
|
||||||
|
sub.revenuecat_original_transaction_id = revenuecat_original_transaction_id
|
||||||
sub.save()
|
sub.save()
|
||||||
|
|
||||||
if log_auth_event:
|
if log_auth_event:
|
||||||
@@ -192,32 +197,30 @@ def assign_plan(
|
|||||||
bool(stripe_subscription_id)
|
bool(stripe_subscription_id)
|
||||||
and prev_stripe_sub != (sub.stripe_subscription_id or "")
|
and prev_stripe_sub != (sub.stripe_subscription_id or "")
|
||||||
)
|
)
|
||||||
|
or (
|
||||||
|
bool(revenuecat_original_transaction_id)
|
||||||
|
and prev_rc_txn != (sub.revenuecat_original_transaction_id or "")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
extra = ""
|
||||||
|
if stripe_subscription_id:
|
||||||
|
extra += f" stripe_subscription_id={stripe_subscription_id}"
|
||||||
|
if revenuecat_original_transaction_id:
|
||||||
|
extra += (
|
||||||
|
f" revenuecat_original_transaction_id="
|
||||||
|
f"{revenuecat_original_transaction_id}"
|
||||||
|
)
|
||||||
if became_active and not had_active:
|
if became_active and not had_active:
|
||||||
log_subscription_auth_event(
|
log_subscription_auth_event(
|
||||||
user,
|
user,
|
||||||
started=True,
|
started=True,
|
||||||
detail=(
|
detail=f"plan={plan.slug} source={source} status={status}{extra}",
|
||||||
f"plan={plan.slug} source={source} status={status}"
|
|
||||||
+ (
|
|
||||||
f" stripe_subscription_id={stripe_subscription_id}"
|
|
||||||
if stripe_subscription_id
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
elif changed:
|
elif changed:
|
||||||
log_subscription_auth_event(
|
log_subscription_auth_event(
|
||||||
user,
|
user,
|
||||||
started=False,
|
started=False,
|
||||||
detail=(
|
detail=f"plan={plan.slug} source={source} status={status}{extra}",
|
||||||
f"plan={plan.slug} source={source} status={status}"
|
|
||||||
+ (
|
|
||||||
f" stripe_subscription_id={stripe_subscription_id}"
|
|
||||||
if stripe_subscription_id
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return sub
|
return sub
|
||||||
|
|
||||||
@@ -243,6 +246,7 @@ def needs_checkout(user) -> bool:
|
|||||||
UserSubscription.Source.BACKER,
|
UserSubscription.Source.BACKER,
|
||||||
UserSubscription.Source.ADMIN,
|
UserSubscription.Source.ADMIN,
|
||||||
UserSubscription.Source.STRIPE,
|
UserSubscription.Source.STRIPE,
|
||||||
|
UserSubscription.Source.REVENUECAT,
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -406,6 +410,147 @@ def resolve_plan_from_stripe_price(price_id: str | None) -> SubscriptionPlan | N
|
|||||||
return SubscriptionPlan.objects.filter(stripe_price_id=price_id).first()
|
return SubscriptionPlan.objects.filter(stripe_price_id=price_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_plan_from_revenuecat_product(
|
||||||
|
product_id: str | None,
|
||||||
|
) -> SubscriptionPlan | None:
|
||||||
|
"""Map a store/RevenueCat product id to a local SubscriptionPlan."""
|
||||||
|
if not product_id:
|
||||||
|
return None
|
||||||
|
seed_subscription_plans(update_existing=False)
|
||||||
|
plan = SubscriptionPlan.objects.filter(
|
||||||
|
revenuecat_product_id=product_id
|
||||||
|
).first()
|
||||||
|
if plan:
|
||||||
|
return plan
|
||||||
|
|
||||||
|
# Optional env map: {"com.app.pro.monthly": "pro", ...}
|
||||||
|
mapping = getattr(settings, "REVENUECAT_PRODUCT_PLAN_MAP", None) or {}
|
||||||
|
if isinstance(mapping, dict):
|
||||||
|
slug = mapping.get(product_id)
|
||||||
|
if slug:
|
||||||
|
plan = get_plan(str(slug))
|
||||||
|
if plan:
|
||||||
|
return plan
|
||||||
|
|
||||||
|
# Heuristic: product id contains a known plan slug.
|
||||||
|
lowered = product_id.lower()
|
||||||
|
for slug in (
|
||||||
|
SubscriptionPlan.Slug.FOUNDERS,
|
||||||
|
SubscriptionPlan.Slug.BUSINESS,
|
||||||
|
SubscriptionPlan.Slug.STANDARD,
|
||||||
|
SubscriptionPlan.Slug.PRO,
|
||||||
|
):
|
||||||
|
if slug in lowered:
|
||||||
|
plan = get_plan(slug)
|
||||||
|
if plan:
|
||||||
|
return plan
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assign_plan_from_revenuecat(
|
||||||
|
user,
|
||||||
|
*,
|
||||||
|
plan_slug: str | None = None,
|
||||||
|
product_id: str | None = None,
|
||||||
|
revenuecat_original_transaction_id: str = "",
|
||||||
|
status: str = UserSubscription.Status.ACTIVE,
|
||||||
|
cancel_at_period_end: bool | None = None,
|
||||||
|
current_period_end=None,
|
||||||
|
keep_existing_plan_if_unknown: bool = False,
|
||||||
|
) -> UserSubscription:
|
||||||
|
"""Assign a catalog plan from a RevenueCat store purchase event."""
|
||||||
|
seed_subscription_plans(update_existing=False)
|
||||||
|
existing = (
|
||||||
|
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
||||||
|
)
|
||||||
|
prev_cancel = bool(existing.cancel_at_period_end) if existing else False
|
||||||
|
prev_period_end = existing.current_period_end if existing else None
|
||||||
|
had_active = bool(
|
||||||
|
existing
|
||||||
|
and existing.status == UserSubscription.Status.ACTIVE
|
||||||
|
and existing.plan_id
|
||||||
|
)
|
||||||
|
|
||||||
|
slug = (plan_slug or "").strip().lower()
|
||||||
|
plan = get_plan(slug) if slug else None
|
||||||
|
if plan is None and product_id:
|
||||||
|
plan = resolve_plan_from_revenuecat_product(product_id)
|
||||||
|
if plan is None and keep_existing_plan_if_unknown and existing and existing.plan_id:
|
||||||
|
plan = existing.plan
|
||||||
|
if plan is None:
|
||||||
|
if slug or product_id:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown RC product/plan product_id=%s plan_slug=%s; "
|
||||||
|
"falling back to Founders for user=%s",
|
||||||
|
product_id,
|
||||||
|
slug,
|
||||||
|
getattr(user, "pk", None),
|
||||||
|
)
|
||||||
|
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS)
|
||||||
|
if plan is None:
|
||||||
|
raise RuntimeError("Founders plan missing from catalog")
|
||||||
|
|
||||||
|
sub = assign_plan(
|
||||||
|
user,
|
||||||
|
plan=plan,
|
||||||
|
source=UserSubscription.Source.REVENUECAT,
|
||||||
|
status=status,
|
||||||
|
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
|
||||||
|
log_auth_event=False,
|
||||||
|
)
|
||||||
|
update_fields: list[str] = []
|
||||||
|
if cancel_at_period_end is not None:
|
||||||
|
sub.cancel_at_period_end = bool(cancel_at_period_end)
|
||||||
|
update_fields.append("cancel_at_period_end")
|
||||||
|
if current_period_end is not None:
|
||||||
|
sub.current_period_end = current_period_end
|
||||||
|
update_fields.append("current_period_end")
|
||||||
|
if update_fields:
|
||||||
|
sub.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
became_active = (
|
||||||
|
sub.status == UserSubscription.Status.ACTIVE and sub.plan_id is not None
|
||||||
|
)
|
||||||
|
cancel_changed = (
|
||||||
|
cancel_at_period_end is not None
|
||||||
|
and bool(cancel_at_period_end) != prev_cancel
|
||||||
|
)
|
||||||
|
period_changed = (
|
||||||
|
current_period_end is not None and current_period_end != prev_period_end
|
||||||
|
)
|
||||||
|
plan_or_status_changed = (
|
||||||
|
not existing
|
||||||
|
or existing.plan_id != sub.plan_id
|
||||||
|
or existing.status != sub.status
|
||||||
|
or (existing.source != sub.source)
|
||||||
|
or (
|
||||||
|
bool(revenuecat_original_transaction_id)
|
||||||
|
and (existing.revenuecat_original_transaction_id or "")
|
||||||
|
!= (sub.revenuecat_original_transaction_id or "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if became_active and not had_active:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=True,
|
||||||
|
detail=(
|
||||||
|
f"plan={sub.plan.slug} source={sub.source} status={sub.status}"
|
||||||
|
f" cancel_at_period_end={sub.cancel_at_period_end}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif plan_or_status_changed or cancel_changed or period_changed:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=False,
|
||||||
|
detail=(
|
||||||
|
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
|
||||||
|
f"source={sub.source} status={sub.status} "
|
||||||
|
f"cancel_at_period_end={sub.cancel_at_period_end}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
def plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
|
def plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
|
||||||
if plan is None:
|
if plan is None:
|
||||||
return None
|
return None
|
||||||
@@ -11,8 +11,8 @@ from django.db.models import Count, Q, Sum
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from chat_backend.models import PromptMetric
|
from chat_backend.models import PromptMetric
|
||||||
from finance.models import UserSubscription
|
from monetization.models import UserSubscription
|
||||||
from finance.services.plans import get_or_create_user_subscription, seed_subscription_plans
|
from monetization.services.plans import get_or_create_user_subscription, seed_subscription_plans
|
||||||
|
|
||||||
|
|
||||||
class QuotaExceeded(Exception):
|
class QuotaExceeded(Exception):
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""RevenueCat store IAP helpers and webhook dispatch (ledger + entitlements)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from monetization.models import Invoice, Payment, UserSubscription
|
||||||
|
from monetization.services.plans import (
|
||||||
|
assign_plan_from_revenuecat,
|
||||||
|
get_or_create_user_subscription,
|
||||||
|
log_subscription_auth_event,
|
||||||
|
resolve_plan_from_revenuecat_product,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# Events that grant or refresh paid access.
|
||||||
|
_ACTIVE_EVENT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"INITIAL_PURCHASE",
|
||||||
|
"RENEWAL",
|
||||||
|
"UNCANCELLATION",
|
||||||
|
"NON_RENEWING_PURCHASE",
|
||||||
|
"PRODUCT_CHANGE",
|
||||||
|
"SUBSCRIPTION_EXTENDED",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Still entitled until period end (cancel scheduled).
|
||||||
|
_CANCEL_AT_PERIOD_END_TYPES = frozenset({"CANCELLATION"})
|
||||||
|
|
||||||
|
# Access ended / payment problems.
|
||||||
|
_EXPIRED_EVENT_TYPES = frozenset({"EXPIRATION"})
|
||||||
|
_BILLING_ISSUE_TYPES = frozenset({"BILLING_ISSUE"})
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueCatWebhookAuthError(ValueError):
|
||||||
|
"""Invalid or missing RevenueCat webhook Authorization header."""
|
||||||
|
|
||||||
|
|
||||||
|
def verify_revenuecat_authorization(
|
||||||
|
*,
|
||||||
|
authorization_header: str | None,
|
||||||
|
expected_secret: str,
|
||||||
|
) -> None:
|
||||||
|
"""Validate ``Authorization: Bearer <secret>`` (or raw secret)."""
|
||||||
|
if not expected_secret:
|
||||||
|
raise RevenueCatWebhookAuthError("REVENUECAT_WEBHOOK_SECRET is not configured")
|
||||||
|
header = (authorization_header or "").strip()
|
||||||
|
if not header:
|
||||||
|
raise RevenueCatWebhookAuthError("Missing Authorization header")
|
||||||
|
token = header
|
||||||
|
if header.lower().startswith("bearer "):
|
||||||
|
token = header[7:].strip()
|
||||||
|
if token != expected_secret:
|
||||||
|
raise RevenueCatWebhookAuthError("Invalid Authorization token")
|
||||||
|
|
||||||
|
|
||||||
|
def _ms_to_dt(value: int | float | None):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ms = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if ms <= 0:
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _price_to_cents(event: dict[str, Any]) -> int:
|
||||||
|
"""RevenueCat ``price`` is major units in USD; prefer purchased currency."""
|
||||||
|
raw = event.get("price_in_purchased_currency")
|
||||||
|
if raw is None:
|
||||||
|
raw = event.get("price")
|
||||||
|
try:
|
||||||
|
return max(0, int(round(float(raw or 0) * 100)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_from_app_user_id(app_user_id: str | None):
|
||||||
|
if not app_user_id:
|
||||||
|
return None
|
||||||
|
# Prefer numeric PK (what the Capacitor client should send via Purchases.logIn).
|
||||||
|
try:
|
||||||
|
return User.objects.get(pk=int(str(app_user_id).strip()))
|
||||||
|
except (User.DoesNotExist, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
# Fallback: email as app user id.
|
||||||
|
user = User.objects.filter(email__iexact=str(app_user_id).strip()).first()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
logger.warning("RevenueCat webhook: app_user_id=%s not found", app_user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _store_label(store: str | None) -> str:
|
||||||
|
return (store or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _description_for_event(event: dict[str, Any]) -> str:
|
||||||
|
store = _store_label(event.get("store"))
|
||||||
|
product = event.get("product_id") or "subscription"
|
||||||
|
etype = event.get("type") or "purchase"
|
||||||
|
parts = [f"Store IAP ({store})" if store else "Store IAP", product, etype]
|
||||||
|
return " — ".join(p for p in parts if p)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def upsert_invoice_from_revenuecat(
|
||||||
|
*,
|
||||||
|
user,
|
||||||
|
event: dict[str, Any],
|
||||||
|
status: str,
|
||||||
|
) -> Invoice:
|
||||||
|
event_id = event.get("id")
|
||||||
|
if not event_id:
|
||||||
|
raise ValueError("RevenueCat event missing id")
|
||||||
|
|
||||||
|
amount = _price_to_cents(event)
|
||||||
|
currency = (event.get("currency") or "usd").lower()
|
||||||
|
period_start = _ms_to_dt(event.get("purchased_at_ms"))
|
||||||
|
period_end = _ms_to_dt(event.get("expiration_at_ms"))
|
||||||
|
amount_paid = amount if status == Invoice.Status.PAID else 0
|
||||||
|
|
||||||
|
invoice, _created = Invoice.objects.update_or_create(
|
||||||
|
revenuecat_event_id=event_id,
|
||||||
|
defaults={
|
||||||
|
"user": user,
|
||||||
|
"company": getattr(user, "company", None),
|
||||||
|
"provider": Invoice.Provider.REVENUECAT,
|
||||||
|
"status": status,
|
||||||
|
"currency": currency,
|
||||||
|
"amount_due": amount,
|
||||||
|
"amount_paid": amount_paid,
|
||||||
|
"period_start": period_start,
|
||||||
|
"period_end": period_end,
|
||||||
|
"revenuecat_store": _store_label(event.get("store")),
|
||||||
|
"description": _description_for_event(event),
|
||||||
|
"hosted_invoice_url": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def upsert_payment_from_revenuecat(
|
||||||
|
*,
|
||||||
|
user,
|
||||||
|
invoice: Invoice | None,
|
||||||
|
event: dict[str, Any],
|
||||||
|
status: str,
|
||||||
|
failure_message: str = "",
|
||||||
|
) -> Payment | None:
|
||||||
|
txn_id = event.get("transaction_id") or event.get("id")
|
||||||
|
if not txn_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
amount = _price_to_cents(event)
|
||||||
|
currency = (event.get("currency") or "usd").lower()
|
||||||
|
paid_at = (
|
||||||
|
_ms_to_dt(event.get("purchased_at_ms"))
|
||||||
|
if status == Payment.Status.SUCCEEDED
|
||||||
|
else None
|
||||||
|
) or (timezone.now() if status == Payment.Status.SUCCEEDED else None)
|
||||||
|
|
||||||
|
payment, _created = Payment.objects.update_or_create(
|
||||||
|
revenuecat_transaction_id=str(txn_id),
|
||||||
|
defaults={
|
||||||
|
"user": user,
|
||||||
|
"company": getattr(user, "company", None),
|
||||||
|
"invoice": invoice,
|
||||||
|
"provider": Payment.Provider.REVENUECAT,
|
||||||
|
"status": status,
|
||||||
|
"currency": currency,
|
||||||
|
"amount": amount,
|
||||||
|
"paid_at": paid_at,
|
||||||
|
"failure_message": failure_message or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
def handle_revenuecat_event(event: dict[str, Any]):
|
||||||
|
"""Apply one RevenueCat ``event`` object: subscription + invoice/payment."""
|
||||||
|
event_type = (event.get("type") or "").upper()
|
||||||
|
app_user_id = event.get("app_user_id") or event.get("original_app_user_id")
|
||||||
|
user = _resolve_user_from_app_user_id(app_user_id)
|
||||||
|
if user is None:
|
||||||
|
# TRANSFER may use different fields; still log.
|
||||||
|
logger.error(
|
||||||
|
"RevenueCat %s: cannot resolve user app_user_id=%s event=%s",
|
||||||
|
event_type,
|
||||||
|
app_user_id,
|
||||||
|
event.get("id"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
product_id = event.get("product_id")
|
||||||
|
original_txn = (
|
||||||
|
event.get("original_transaction_id")
|
||||||
|
or event.get("transaction_id")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
period_end = _ms_to_dt(event.get("expiration_at_ms"))
|
||||||
|
plan = resolve_plan_from_revenuecat_product(product_id)
|
||||||
|
|
||||||
|
if event_type in _ACTIVE_EVENT_TYPES:
|
||||||
|
invoice = upsert_invoice_from_revenuecat(
|
||||||
|
user=user, event=event, status=Invoice.Status.PAID
|
||||||
|
)
|
||||||
|
upsert_payment_from_revenuecat(
|
||||||
|
user=user,
|
||||||
|
invoice=invoice,
|
||||||
|
event=event,
|
||||||
|
status=Payment.Status.SUCCEEDED,
|
||||||
|
)
|
||||||
|
return assign_plan_from_revenuecat(
|
||||||
|
user,
|
||||||
|
plan_slug=plan.slug if plan else None,
|
||||||
|
product_id=product_id,
|
||||||
|
revenuecat_original_transaction_id=str(original_txn),
|
||||||
|
status=UserSubscription.Status.ACTIVE,
|
||||||
|
cancel_at_period_end=False,
|
||||||
|
current_period_end=period_end,
|
||||||
|
keep_existing_plan_if_unknown=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_type in _CANCEL_AT_PERIOD_END_TYPES:
|
||||||
|
# User canceled in store; access continues until expiration.
|
||||||
|
invoice = upsert_invoice_from_revenuecat(
|
||||||
|
user=user, event=event, status=Invoice.Status.OPEN
|
||||||
|
)
|
||||||
|
return assign_plan_from_revenuecat(
|
||||||
|
user,
|
||||||
|
plan_slug=plan.slug if plan else None,
|
||||||
|
product_id=product_id,
|
||||||
|
revenuecat_original_transaction_id=str(original_txn),
|
||||||
|
status=UserSubscription.Status.ACTIVE,
|
||||||
|
cancel_at_period_end=True,
|
||||||
|
current_period_end=period_end,
|
||||||
|
keep_existing_plan_if_unknown=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_type in _BILLING_ISSUE_TYPES:
|
||||||
|
invoice = upsert_invoice_from_revenuecat(
|
||||||
|
user=user, event=event, status=Invoice.Status.PAYMENT_FAILED
|
||||||
|
)
|
||||||
|
upsert_payment_from_revenuecat(
|
||||||
|
user=user,
|
||||||
|
invoice=invoice,
|
||||||
|
event=event,
|
||||||
|
status=Payment.Status.FAILED,
|
||||||
|
failure_message="Store billing issue",
|
||||||
|
)
|
||||||
|
return assign_plan_from_revenuecat(
|
||||||
|
user,
|
||||||
|
plan_slug=plan.slug if plan else None,
|
||||||
|
product_id=product_id,
|
||||||
|
revenuecat_original_transaction_id=str(original_txn),
|
||||||
|
status=UserSubscription.Status.PAST_DUE,
|
||||||
|
current_period_end=period_end,
|
||||||
|
keep_existing_plan_if_unknown=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_type in _EXPIRED_EVENT_TYPES:
|
||||||
|
upsert_invoice_from_revenuecat(
|
||||||
|
user=user, event=event, status=Invoice.Status.VOID
|
||||||
|
)
|
||||||
|
sub = get_or_create_user_subscription(user)
|
||||||
|
prev_status = sub.status
|
||||||
|
sub.status = UserSubscription.Status.CANCELED
|
||||||
|
sub.cancel_at_period_end = False
|
||||||
|
if period_end:
|
||||||
|
sub.current_period_end = period_end
|
||||||
|
if original_txn:
|
||||||
|
sub.revenuecat_original_transaction_id = str(original_txn)
|
||||||
|
if sub.source == UserSubscription.Source.NONE:
|
||||||
|
sub.source = UserSubscription.Source.REVENUECAT
|
||||||
|
elif sub.source != UserSubscription.Source.REVENUECAT:
|
||||||
|
# Only expire if this was a store sub; leave Stripe alone.
|
||||||
|
if sub.source == UserSubscription.Source.STRIPE:
|
||||||
|
logger.info(
|
||||||
|
"Ignoring RC EXPIRATION for Stripe-sourced user=%s", user.pk
|
||||||
|
)
|
||||||
|
return sub
|
||||||
|
sub.source = UserSubscription.Source.REVENUECAT
|
||||||
|
sub.save()
|
||||||
|
if prev_status != UserSubscription.Status.CANCELED:
|
||||||
|
log_subscription_auth_event(
|
||||||
|
user,
|
||||||
|
started=False,
|
||||||
|
detail=(
|
||||||
|
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
|
||||||
|
f"source={sub.source} status={sub.status} "
|
||||||
|
f"revenuecat_original_transaction_id="
|
||||||
|
f"{sub.revenuecat_original_transaction_id}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
logger.info("Ignoring unhandled RevenueCat event type: %s", event_type)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_revenuecat_event(payload: dict[str, Any]):
|
||||||
|
"""Route a verified RevenueCat webhook JSON body."""
|
||||||
|
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
raise ValueError("RevenueCat payload missing event object")
|
||||||
|
return handle_revenuecat_event(event)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Stripe Checkout and Billing Portal session helpers."""
|
"""Stripe Checkout, Billing Portal, and webhook dispatch."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,8 +7,20 @@ from typing import Any
|
|||||||
import stripe
|
import stripe
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from finance.models import Invoice, SubscriptionPlan
|
from monetization.models import Invoice, SubscriptionPlan
|
||||||
from finance.services.plans import get_plan, seed_subscription_plans
|
from monetization.services.plans import get_plan, seed_subscription_plans
|
||||||
|
from monetization.services.webhooks import dispatch_stripe_event
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"StripeNotConfiguredError",
|
||||||
|
"configure_stripe",
|
||||||
|
"resolve_checkout_plan",
|
||||||
|
"subscription_line_items",
|
||||||
|
"create_checkout_session",
|
||||||
|
"resolve_stripe_customer_id",
|
||||||
|
"create_billing_portal_session",
|
||||||
|
"dispatch_stripe_event",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class StripeNotConfiguredError(RuntimeError):
|
class StripeNotConfiguredError(RuntimeError):
|
||||||
@@ -10,8 +10,8 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from finance.models import Invoice, Payment, UserSubscription
|
from monetization.models import Invoice, Payment, UserSubscription
|
||||||
from finance.services.plans import (
|
from monetization.services.plans import (
|
||||||
assign_plan_from_stripe,
|
assign_plan_from_stripe,
|
||||||
get_or_create_user_subscription,
|
get_or_create_user_subscription,
|
||||||
log_subscription_auth_event,
|
log_subscription_auth_event,
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
|
|
||||||
def seed_plans_on_migrate(sender, **kwargs):
|
def seed_plans_on_migrate(sender, **kwargs):
|
||||||
"""Ensure the subscription catalog exists after migrate."""
|
"""Ensure the subscription catalog exists after migrate."""
|
||||||
from finance.services.plans import seed_subscription_plans
|
from monetization.services.plans import seed_subscription_plans
|
||||||
|
|
||||||
seed_subscription_plans(update_existing=False)
|
seed_subscription_plans(update_existing=False)
|
||||||
@@ -8,7 +8,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from chat_backend.tests.factories import make_company, make_user
|
from chat_backend.tests.factories import make_company, make_user
|
||||||
from finance.models import Invoice
|
from monetization.models import Invoice
|
||||||
|
|
||||||
|
|
||||||
class CreateCheckoutSessionViewTestCase(APITestCase):
|
class CreateCheckoutSessionViewTestCase(APITestCase):
|
||||||
@@ -28,7 +28,7 @@ class CreateCheckoutSessionViewTestCase(APITestCase):
|
|||||||
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
|
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
|
||||||
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
|
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
|
||||||
)
|
)
|
||||||
@patch("finance.services.stripe_service.stripe.checkout.Session.create")
|
@patch("monetization.services.stripe.stripe.checkout.Session.create")
|
||||||
def test_creates_checkout_session_and_draft_invoice(self, mock_create):
|
def test_creates_checkout_session_and_draft_invoice(self, mock_create):
|
||||||
mock_session = MagicMock()
|
mock_session = MagicMock()
|
||||||
mock_session.id = "cs_test_abc"
|
mock_session.id = "cs_test_abc"
|
||||||
@@ -78,7 +78,7 @@ class CreateCheckoutSessionViewTestCase(APITestCase):
|
|||||||
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
|
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
|
||||||
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
|
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
|
||||||
)
|
)
|
||||||
@patch("finance.services.stripe_service.stripe.checkout.Session.create")
|
@patch("monetization.services.stripe.stripe.checkout.Session.create")
|
||||||
def test_uses_stripe_price_id_when_set(self, mock_create):
|
def test_uses_stripe_price_id_when_set(self, mock_create):
|
||||||
mock_session = MagicMock()
|
mock_session = MagicMock()
|
||||||
mock_session.id = "cs_test_price"
|
mock_session.id = "cs_test_price"
|
||||||
@@ -4,7 +4,7 @@ from django.contrib import admin
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from chat_backend.tests.factories import make_company, make_user
|
from chat_backend.tests.factories import make_company, make_user
|
||||||
from finance.models import Invoice, Payment
|
from monetization.models import Invoice, Payment
|
||||||
|
|
||||||
|
|
||||||
class InvoicePaymentModelTestCase(TestCase):
|
class InvoicePaymentModelTestCase(TestCase):
|
||||||
+4
-4
@@ -11,14 +11,14 @@ from rest_framework.test import APITestCase
|
|||||||
|
|
||||||
from chat_backend.models import PromptMetric
|
from chat_backend.models import PromptMetric
|
||||||
from chat_backend.tests.factories import make_company, make_conversation, make_user
|
from chat_backend.tests.factories import make_company, make_conversation, make_user
|
||||||
from finance.models import BackerEmail, SubscriptionPlan, UserSubscription
|
from monetization.models import BackerEmail, SubscriptionPlan, UserSubscription
|
||||||
from finance.services.plans import (
|
from monetization.services.plans import (
|
||||||
assign_plan,
|
assign_plan,
|
||||||
needs_checkout,
|
needs_checkout,
|
||||||
seed_subscription_plans,
|
seed_subscription_plans,
|
||||||
try_redeem_backer_email,
|
try_redeem_backer_email,
|
||||||
)
|
)
|
||||||
from finance.services.quotas import (
|
from monetization.services.quotas import (
|
||||||
FeatureNotAllowed,
|
FeatureNotAllowed,
|
||||||
QuotaExceeded,
|
QuotaExceeded,
|
||||||
assert_feature_allowed,
|
assert_feature_allowed,
|
||||||
@@ -227,7 +227,7 @@ class CheckoutUsesFoundersPlanTestCase(APITestCase):
|
|||||||
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
|
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
|
||||||
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
|
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
|
||||||
)
|
)
|
||||||
@patch("finance.services.stripe_service.stripe.checkout.Session.create")
|
@patch("monetization.services.stripe.stripe.checkout.Session.create")
|
||||||
def test_checkout_defaults_to_founders(self, mock_create):
|
def test_checkout_defaults_to_founders(self, mock_create):
|
||||||
mock_session = MagicMock()
|
mock_session = MagicMock()
|
||||||
mock_session.id = "cs_test_founders"
|
mock_session.id = "cs_test_founders"
|
||||||
@@ -8,7 +8,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from chat_backend.tests.factories import make_company, make_user
|
from chat_backend.tests.factories import make_company, make_user
|
||||||
from finance.models import Invoice
|
from monetization.models import Invoice
|
||||||
|
|
||||||
|
|
||||||
class CreateBillingPortalSessionViewTestCase(APITestCase):
|
class CreateBillingPortalSessionViewTestCase(APITestCase):
|
||||||
@@ -35,7 +35,7 @@ class CreateBillingPortalSessionViewTestCase(APITestCase):
|
|||||||
STRIPE_SECRET_KEY="sk_test_fake",
|
STRIPE_SECRET_KEY="sk_test_fake",
|
||||||
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
|
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
|
||||||
)
|
)
|
||||||
@patch("finance.services.stripe_service.stripe.billing_portal.Session.create")
|
@patch("monetization.services.stripe.stripe.billing_portal.Session.create")
|
||||||
def test_creates_portal_session(self, mock_create):
|
def test_creates_portal_session(self, mock_create):
|
||||||
self._create_invoice_with_customer()
|
self._create_invoice_with_customer()
|
||||||
mock_session = MagicMock()
|
mock_session = MagicMock()
|
||||||
@@ -58,7 +58,7 @@ class CreateBillingPortalSessionViewTestCase(APITestCase):
|
|||||||
STRIPE_SECRET_KEY="sk_test_fake",
|
STRIPE_SECRET_KEY="sk_test_fake",
|
||||||
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
|
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
|
||||||
)
|
)
|
||||||
@patch("finance.services.stripe_service.stripe.billing_portal.Session.create")
|
@patch("monetization.services.stripe.stripe.billing_portal.Session.create")
|
||||||
def test_accepts_custom_return_url(self, mock_create):
|
def test_accepts_custom_return_url(self, mock_create):
|
||||||
self._create_invoice_with_customer()
|
self._create_invoice_with_customer()
|
||||||
mock_session = MagicMock()
|
mock_session = MagicMock()
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests for RevenueCat webhook auth, ledger upserts, and plan assignment."""
|
||||||
|
|
||||||
|
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 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.revenuecat import (
|
||||||
|
RevenueCatWebhookAuthError,
|
||||||
|
dispatch_revenuecat_event,
|
||||||
|
verify_revenuecat_authorization,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rc_event(**overrides):
|
||||||
|
base = {
|
||||||
|
"id": "rc_evt_1",
|
||||||
|
"type": "INITIAL_PURCHASE",
|
||||||
|
"app_user_id": "1",
|
||||||
|
"product_id": "hesychia_founders_monthly",
|
||||||
|
"store": "PLAY_STORE",
|
||||||
|
"price": 10.0,
|
||||||
|
"currency": "USD",
|
||||||
|
"purchased_at_ms": 1_700_000_000_000,
|
||||||
|
"expiration_at_ms": 1_702_592_000_000,
|
||||||
|
"transaction_id": "GPA.1234",
|
||||||
|
"original_transaction_id": "GPA.1234",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueCatAuthTests(TestCase):
|
||||||
|
def test_bearer_token_ok(self):
|
||||||
|
verify_revenuecat_authorization(
|
||||||
|
authorization_header="Bearer secret-token",
|
||||||
|
expected_secret="secret-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_raw_token_ok(self):
|
||||||
|
verify_revenuecat_authorization(
|
||||||
|
authorization_header="secret-token",
|
||||||
|
expected_secret="secret-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bad_token(self):
|
||||||
|
with self.assertRaises(RevenueCatWebhookAuthError):
|
||||||
|
verify_revenuecat_authorization(
|
||||||
|
authorization_header="Bearer nope",
|
||||||
|
expected_secret="secret-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueCatDispatchTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
seed_subscription_plans(update_existing=False)
|
||||||
|
self.user = make_user(email="rc@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_initial_purchase_assigns_plan_and_ledger(self):
|
||||||
|
event = _rc_event(app_user_id=str(self.user.pk))
|
||||||
|
dispatch_revenuecat_event({"api_version": "1.0", "event": event})
|
||||||
|
|
||||||
|
sub = UserSubscription.objects.get(user=self.user)
|
||||||
|
self.assertEqual(sub.source, UserSubscription.Source.REVENUECAT)
|
||||||
|
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
||||||
|
self.assertEqual(sub.plan.slug, "founders")
|
||||||
|
self.assertEqual(sub.revenuecat_original_transaction_id, "GPA.1234")
|
||||||
|
|
||||||
|
invoice = Invoice.objects.get(revenuecat_event_id="rc_evt_1")
|
||||||
|
self.assertEqual(invoice.provider, Invoice.Provider.REVENUECAT)
|
||||||
|
self.assertEqual(invoice.status, Invoice.Status.PAID)
|
||||||
|
self.assertEqual(invoice.amount_paid, 1000)
|
||||||
|
self.assertEqual(invoice.revenuecat_store, "PLAY_STORE")
|
||||||
|
self.assertIn("PLAY_STORE", invoice.description)
|
||||||
|
|
||||||
|
payment = Payment.objects.get(revenuecat_transaction_id="GPA.1234")
|
||||||
|
self.assertEqual(payment.provider, Payment.Provider.REVENUECAT)
|
||||||
|
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
|
||||||
|
self.assertEqual(payment.invoice_id, invoice.pk)
|
||||||
|
|
||||||
|
def test_idempotent_replay(self):
|
||||||
|
event = _rc_event(app_user_id=str(self.user.pk))
|
||||||
|
dispatch_revenuecat_event({"event": event})
|
||||||
|
dispatch_revenuecat_event({"event": event})
|
||||||
|
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 1)
|
||||||
|
self.assertEqual(Payment.objects.filter(user=self.user).count(), 1)
|
||||||
|
|
||||||
|
def test_cancellation_sets_cancel_at_period_end(self):
|
||||||
|
dispatch_revenuecat_event(
|
||||||
|
{"event": _rc_event(app_user_id=str(self.user.pk))}
|
||||||
|
)
|
||||||
|
dispatch_revenuecat_event(
|
||||||
|
{
|
||||||
|
"event": _rc_event(
|
||||||
|
id="rc_evt_cancel",
|
||||||
|
type="CANCELLATION",
|
||||||
|
app_user_id=str(self.user.pk),
|
||||||
|
transaction_id="GPA.999",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sub = UserSubscription.objects.get(user=self.user)
|
||||||
|
self.assertTrue(sub.cancel_at_period_end)
|
||||||
|
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
|
||||||
|
|
||||||
|
def test_expiration_cancels(self):
|
||||||
|
dispatch_revenuecat_event(
|
||||||
|
{"event": _rc_event(app_user_id=str(self.user.pk))}
|
||||||
|
)
|
||||||
|
dispatch_revenuecat_event(
|
||||||
|
{
|
||||||
|
"event": _rc_event(
|
||||||
|
id="rc_evt_exp",
|
||||||
|
type="EXPIRATION",
|
||||||
|
app_user_id=str(self.user.pk),
|
||||||
|
transaction_id="GPA.exp",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sub = UserSubscription.objects.get(user=self.user)
|
||||||
|
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()
|
||||||
|
)
|
||||||
@@ -9,9 +9,9 @@ from rest_framework.test import APITestCase
|
|||||||
|
|
||||||
from chat_backend.tests.factories import make_company, make_user
|
from chat_backend.tests.factories import make_company, make_user
|
||||||
from chat_backend.models import UserAuthEvent
|
from chat_backend.models import UserAuthEvent
|
||||||
from finance.models import Invoice, Payment, UserSubscription
|
from monetization.models import Invoice, Payment, UserSubscription
|
||||||
from finance.services.plans import assign_plan_from_stripe, seed_subscription_plans
|
from monetization.services.plans import assign_plan_from_stripe, seed_subscription_plans
|
||||||
from finance.services.webhooks import (
|
from monetization.services.webhooks import (
|
||||||
dispatch_stripe_event,
|
dispatch_stripe_event,
|
||||||
handle_checkout_session_completed,
|
handle_checkout_session_completed,
|
||||||
handle_customer_subscription_deleted,
|
handle_customer_subscription_deleted,
|
||||||
@@ -203,7 +203,7 @@ class StripeWebhookViewTestCase(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
|
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
|
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
|
||||||
@patch("finance.views.stripe.Webhook.construct_event")
|
@patch("monetization.views.stripe.Webhook.construct_event")
|
||||||
def test_invalid_signature_returns_400(self, mock_construct):
|
def test_invalid_signature_returns_400(self, mock_construct):
|
||||||
import stripe
|
import stripe
|
||||||
|
|
||||||
@@ -219,8 +219,8 @@ class StripeWebhookViewTestCase(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
|
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
|
||||||
@patch("finance.views.dispatch_stripe_event")
|
@patch("monetization.views.dispatch_stripe_event")
|
||||||
@patch("finance.views.stripe.Webhook.construct_event")
|
@patch("monetization.views.stripe.Webhook.construct_event")
|
||||||
def test_valid_event_dispatched(self, mock_construct, mock_dispatch):
|
def test_valid_event_dispatched(self, mock_construct, mock_dispatch):
|
||||||
mock_construct.return_value = {
|
mock_construct.return_value = {
|
||||||
"id": "evt_1",
|
"id": "evt_1",
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from finance.views import (
|
from monetization.views import (
|
||||||
CreateBillingPortalSessionView,
|
CreateBillingPortalSessionView,
|
||||||
CreateCheckoutSessionView,
|
CreateCheckoutSessionView,
|
||||||
InvoiceListView,
|
InvoiceListView,
|
||||||
PaymentListView,
|
PaymentListView,
|
||||||
PlanListView,
|
PlanListView,
|
||||||
|
RevenueCatWebhookView,
|
||||||
StripeWebhookView,
|
StripeWebhookView,
|
||||||
SubscriptionMeView,
|
SubscriptionMeView,
|
||||||
)
|
)
|
||||||
@@ -46,4 +47,9 @@ urlpatterns = [
|
|||||||
StripeWebhookView.as_view(),
|
StripeWebhookView.as_view(),
|
||||||
name="finance_stripe_webhook",
|
name="finance_stripe_webhook",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"webhooks/revenuecat/",
|
||||||
|
RevenueCatWebhookView.as_view(),
|
||||||
|
name="finance_revenuecat_webhook",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
@@ -6,27 +6,32 @@ from rest_framework import permissions, status
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from finance.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
|
||||||
from finance.serializers import (
|
from monetization.serializers import (
|
||||||
CheckoutSessionSerializer,
|
CheckoutSessionSerializer,
|
||||||
InvoiceSerializer,
|
InvoiceSerializer,
|
||||||
PaymentSerializer,
|
PaymentSerializer,
|
||||||
PortalSessionSerializer,
|
PortalSessionSerializer,
|
||||||
SubscriptionPlanSerializer,
|
SubscriptionPlanSerializer,
|
||||||
)
|
)
|
||||||
from finance.services.plans import (
|
from monetization.services.plans import (
|
||||||
needs_checkout,
|
needs_checkout,
|
||||||
plan_to_dict,
|
plan_to_dict,
|
||||||
seed_subscription_plans,
|
seed_subscription_plans,
|
||||||
)
|
)
|
||||||
from finance.services.quotas import get_usage_snapshot
|
from monetization.services.quotas import get_usage_snapshot
|
||||||
from finance.services.stripe_service import (
|
from monetization.services.revenuecat import (
|
||||||
|
RevenueCatWebhookAuthError,
|
||||||
|
dispatch_revenuecat_event,
|
||||||
|
verify_revenuecat_authorization,
|
||||||
|
)
|
||||||
|
from monetization.services.stripe import (
|
||||||
StripeNotConfiguredError,
|
StripeNotConfiguredError,
|
||||||
create_billing_portal_session,
|
create_billing_portal_session,
|
||||||
create_checkout_session,
|
create_checkout_session,
|
||||||
|
dispatch_stripe_event,
|
||||||
resolve_stripe_customer_id,
|
resolve_stripe_customer_id,
|
||||||
)
|
)
|
||||||
from finance.services.webhooks import dispatch_stripe_event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -199,6 +204,9 @@ class SubscriptionMeView(APIView):
|
|||||||
"stripe_subscription_id": (
|
"stripe_subscription_id": (
|
||||||
sub.stripe_subscription_id if sub else ""
|
sub.stripe_subscription_id if sub else ""
|
||||||
),
|
),
|
||||||
|
"revenuecat_original_transaction_id": (
|
||||||
|
sub.revenuecat_original_transaction_id if sub else ""
|
||||||
|
),
|
||||||
"cancel_at_period_end": bool(sub.cancel_at_period_end) if sub else False,
|
"cancel_at_period_end": bool(sub.cancel_at_period_end) if sub else False,
|
||||||
"current_period_end": (
|
"current_period_end": (
|
||||||
sub.current_period_end.isoformat()
|
sub.current_period_end.isoformat()
|
||||||
@@ -258,3 +266,50 @@ 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