diff --git a/.env.example b/.env.example index c5ac839..1fd3339 100644 --- a/.env.example +++ b/.env.example @@ -76,13 +76,17 @@ OAUTH_CALLBACK_BASE_URL=http://127.0.0.1:8001 # POST {OAUTH_CALLBACK_BASE_URL}/api/drive/webhooks/microsoft/ # 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_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= # Optional: pre-created Stripe Price ID for Founders. When empty, Checkout uses # SubscriptionPlan.price_cents / SUBSCRIPTION_PRICE_* ($10 USD / month Founders). STRIPE_PRICE_ID= +# RevenueCat webhook Authorization bearer secret (store IAP). +REVENUECAT_WEBHOOK_SECRET= +# 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_CURRENCY=usd # SUBSCRIPTION_PRICE_INTERVAL=month diff --git a/.env.prod.example b/.env.prod.example index 2a499a7..038a85e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -87,10 +87,12 @@ OAUTH_CALLBACK_BASE_URL=https://chatbackend.aimloperations.com # https://chatbackend.aimloperations.com/api/drive/webhooks/microsoft/ # 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_PUBLISHABLE_KEY=replace-with-stripe-publishable-key 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 # SUBSCRIPTION_PRICE_* from settings.py ($10 USD / month by default). STRIPE_PRICE_ID= diff --git a/README.md b/README.md index d617f1b..e5be19b 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ with `COMPOSE_DATABASE_URL` if needed. | `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO | | `CAPTCHA_SECRET_KEY` | empty | recommended | | | `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready | -| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook | +| `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 | | `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 | @@ -276,9 +278,19 @@ Post-delete UX: clear local tokens → redirect to sign-in. Subsequent ### 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 `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 `current_period_end` for Account UI messaging. diff --git a/llm_be/chat_backend/consumers.py b/llm_be/chat_backend/consumers.py index d22bb67..43fcb62 100644 --- a/llm_be/chat_backend/consumers.py +++ b/llm_be/chat_backend/consumers.py @@ -47,7 +47,7 @@ from .utils import ( is_heartbeat_payload, normalize_user_message, ) -from finance.services.quotas import ( +from monetization.services.quotas import ( FeatureNotAllowed, QuotaExceeded, check_generation_allowed, @@ -88,7 +88,7 @@ def enforce_generation_gates(user, feature="text_generation"): @database_sync_to_async 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) diff --git a/llm_be/chat_backend/consumers_graph.py b/llm_be/chat_backend/consumers_graph.py index 3472ac7..007fc71 100644 --- a/llm_be/chat_backend/consumers_graph.py +++ b/llm_be/chat_backend/consumers_graph.py @@ -39,7 +39,7 @@ from .utils import ( is_heartbeat_payload, 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__) @@ -74,7 +74,7 @@ def enforce_generation_gates(user, feature="text_generation"): @database_sync_to_async 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) diff --git a/llm_be/chat_backend/oauth.py b/llm_be/chat_backend/oauth.py index c3ab900..936606b 100644 --- a/llm_be/chat_backend/oauth.py +++ b/llm_be/chat_backend/oauth.py @@ -389,7 +389,7 @@ def upsert_drive_connection( 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( name=f"{profile.email}'s workspace", diff --git a/llm_be/chat_backend/serializers.py b/llm_be/chat_backend/serializers.py index f162244..b76159f 100644 --- a/llm_be/chat_backend/serializers.py +++ b/llm_be/chat_backend/serializers.py @@ -71,8 +71,8 @@ class CustomUserSerializer(serializers.ModelSerializer): extra_kwargs = {"password": {"write_only": True}} def get_subscription(self, obj): - from finance.services.plans import needs_checkout, plan_to_dict - from finance.models import UserSubscription + from monetization.services.plans import needs_checkout, plan_to_dict + from monetization.models import UserSubscription try: sub = obj.subscription @@ -121,7 +121,7 @@ class SelfServeRegistrationSerializer(serializers.Serializer): return email 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"] password = validated_data["password"] diff --git a/llm_be/chat_backend/tests/test_consumers.py b/llm_be/chat_backend/tests/test_consumers.py index 623ac0e..4276baa 100644 --- a/llm_be/chat_backend/tests/test_consumers.py +++ b/llm_be/chat_backend/tests/test_consumers.py @@ -382,8 +382,8 @@ class GraphNodeTestCase(TransactionTestCase): @override_settings(ENFORCE_SUBSCRIPTION_GATES=True) async def test_generation_node_denies_rag_on_standard_plan(self): - from finance.models import SubscriptionPlan, UserSubscription - from finance.services.plans import assign_plan, seed_subscription_plans + from monetization.models import SubscriptionPlan, UserSubscription + from monetization.services.plans import assign_plan, seed_subscription_plans await sync_to_async(seed_subscription_plans)() standard = await sync_to_async(SubscriptionPlan.objects.get)(slug="standard") @@ -403,8 +403,8 @@ class GraphNodeTestCase(TransactionTestCase): @override_settings(ENFORCE_SUBSCRIPTION_GATES=True) async def test_generation_node_allows_rag_with_founders_plan(self): - from finance.models import SubscriptionPlan, UserSubscription - from finance.services.plans import assign_plan, seed_subscription_plans + from monetization.models import SubscriptionPlan, UserSubscription + from monetization.services.plans import assign_plan, seed_subscription_plans await sync_to_async(seed_subscription_plans)() founders = await sync_to_async(SubscriptionPlan.objects.get)(slug="founders") diff --git a/llm_be/chat_backend/tests/test_oauth.py b/llm_be/chat_backend/tests/test_oauth.py index 902bb92..0f8c255 100644 --- a/llm_be/chat_backend/tests/test_oauth.py +++ b/llm_be/chat_backend/tests/test_oauth.py @@ -305,8 +305,8 @@ class OAuthStartDriveLinkTestCase(APITestCase): @override_settings(ENFORCE_SUBSCRIPTION_GATES=True) def test_link_drive_denied_when_plan_disallows_rag(self): - from finance.services.plans import assign_plan, seed_subscription_plans - from finance.models import SubscriptionPlan, UserSubscription + from monetization.services.plans import assign_plan, seed_subscription_plans + from monetization.models import SubscriptionPlan, UserSubscription seed_subscription_plans() standard = SubscriptionPlan.objects.get(slug="standard") @@ -493,8 +493,8 @@ class OAuthCallbackDriveLinkTestCase(APITestCase): @override_settings(ENFORCE_SUBSCRIPTION_GATES=True) @patch("chat_backend.views_oauth.exchange_code_for_profile") def test_callback_denied_when_plan_disallows_rag(self, mock_exchange): - from finance.services.plans import assign_plan, seed_subscription_plans - from finance.models import SubscriptionPlan, UserSubscription + from monetization.services.plans import assign_plan, seed_subscription_plans + from monetization.models import SubscriptionPlan, UserSubscription seed_subscription_plans() standard = SubscriptionPlan.objects.get(slug="standard") diff --git a/llm_be/chat_backend/tests/test_views_documents.py b/llm_be/chat_backend/tests/test_views_documents.py index 1d378ba..4a1d159 100644 --- a/llm_be/chat_backend/tests/test_views_documents.py +++ b/llm_be/chat_backend/tests/test_views_documents.py @@ -7,8 +7,8 @@ from rest_framework import status from rest_framework.test import APITestCase from chat_backend.models import Document, DocumentWorkspace, StoredFile -from finance.models import SubscriptionPlan, UserSubscription -from finance.services.plans import assign_plan, seed_subscription_plans +from monetization.models import SubscriptionPlan, UserSubscription +from monetization.services.plans import assign_plan, seed_subscription_plans from .factories import ( make_company, diff --git a/llm_be/chat_backend/tests/test_views_drive.py b/llm_be/chat_backend/tests/test_views_drive.py index f18fb0c..adb88e2 100644 --- a/llm_be/chat_backend/tests/test_views_drive.py +++ b/llm_be/chat_backend/tests/test_views_drive.py @@ -10,8 +10,8 @@ from rest_framework import status from rest_framework.test import APITestCase from chat_backend.models import DriveConnection -from finance.models import SubscriptionPlan, UserSubscription -from finance.services.plans import assign_plan, seed_subscription_plans +from monetization.models import SubscriptionPlan, UserSubscription +from monetization.services.plans import assign_plan, seed_subscription_plans from .factories import make_company, make_drive_connection, make_user diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index 8c2326f..b16ad60 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -66,7 +66,7 @@ from .email_tasks import ( send_invite_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.rag_services import AsyncRAGService from .services.chat_tenant_scope import ( @@ -143,7 +143,7 @@ class CustomUserCreate(APIView): user = serializer.save() refresh = RefreshToken.for_user(user) - from finance.services.plans import needs_checkout + from monetization.services.plans import needs_checkout return Response( { diff --git a/llm_be/chat_backend/views_drive.py b/llm_be/chat_backend/views_drive.py index 7b73147..5cea8b0 100644 --- a/llm_be/chat_backend/views_drive.py +++ b/llm_be/chat_backend/views_drive.py @@ -10,7 +10,7 @@ from rest_framework import permissions, status from rest_framework.response import Response 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 .models import DriveConnection diff --git a/llm_be/chat_backend/views_oauth.py b/llm_be/chat_backend/views_oauth.py index 334e645..f3028ee 100644 --- a/llm_be/chat_backend/views_oauth.py +++ b/llm_be/chat_backend/views_oauth.py @@ -14,7 +14,7 @@ from rest_framework.views import APIView from rest_framework_simplejwt.authentication import JWTAuthentication 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 .oauth import ( @@ -201,7 +201,7 @@ class OAuthCallbackView(APIView): return _redirect_error("server_error", "Unexpected OAuth error.") 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" return HttpResponseRedirect( diff --git a/llm_be/finance/apps.py b/llm_be/finance/apps.py deleted file mode 100644 index ece0e44..0000000 --- a/llm_be/finance/apps.py +++ /dev/null @@ -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) diff --git a/llm_be/llm_be/settings.py b/llm_be/llm_be/settings.py index 7b0389e..d06a3d1 100644 --- a/llm_be/llm_be/settings.py +++ b/llm_be/llm_be/settings.py @@ -181,7 +181,7 @@ INSTALLED_APPS = [ "whitenoise.runserver_nostatic", "django.contrib.staticfiles", "chat_backend", - "finance.apps.FinanceConfig", + "monetization.apps.MonetizationConfig", "rest_framework", "corsheaders", "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("/") # --------------------------------------------------------------------------- -# Finance / Stripe (subscription billing) +# Monetization / Stripe + RevenueCat (subscription billing) # --------------------------------------------------------------------------- STRIPE_SECRET_KEY = env("STRIPE_SECRET_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. 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_PRICE_AMOUNT_CENTS = int( env("SUBSCRIPTION_PRICE_AMOUNT_CENTS", "1000") or "1000" diff --git a/llm_be/llm_be/urls.py b/llm_be/llm_be/urls.py index 6e3b337..673b977 100644 --- a/llm_be/llm_be/urls.py +++ b/llm_be/llm_be/urls.py @@ -23,7 +23,8 @@ urlpatterns = ( [ path("admin/", admin.site.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.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/llm_be/finance/__init__.py b/llm_be/monetization/__init__.py similarity index 100% rename from llm_be/finance/__init__.py rename to llm_be/monetization/__init__.py diff --git a/llm_be/finance/admin.py b/llm_be/monetization/admin.py similarity index 97% rename from llm_be/finance/admin.py rename to llm_be/monetization/admin.py index de93b58..3f5d343 100644 --- a/llm_be/finance/admin.py +++ b/llm_be/monetization/admin.py @@ -1,6 +1,6 @@ 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) diff --git a/llm_be/monetization/apps.py b/llm_be/monetization/apps.py new file mode 100644 index 0000000..c1f0968 --- /dev/null +++ b/llm_be/monetization/apps.py @@ -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) diff --git a/llm_be/finance/migrations/0001_initial.py b/llm_be/monetization/migrations/0001_initial.py similarity index 100% rename from llm_be/finance/migrations/0001_initial.py rename to llm_be/monetization/migrations/0001_initial.py diff --git a/llm_be/finance/migrations/0002_subscription_plans_quotas.py b/llm_be/monetization/migrations/0002_subscription_plans_quotas.py similarity index 100% rename from llm_be/finance/migrations/0002_subscription_plans_quotas.py rename to llm_be/monetization/migrations/0002_subscription_plans_quotas.py diff --git a/llm_be/finance/migrations/0003_subscription_cancel_period_fields.py b/llm_be/monetization/migrations/0003_subscription_cancel_period_fields.py similarity index 100% rename from llm_be/finance/migrations/0003_subscription_cancel_period_fields.py rename to llm_be/monetization/migrations/0003_subscription_cancel_period_fields.py diff --git a/llm_be/finance/migrations/0004_subscriptionplan_allows_rag.py b/llm_be/monetization/migrations/0004_subscriptionplan_allows_rag.py similarity index 100% rename from llm_be/finance/migrations/0004_subscriptionplan_allows_rag.py rename to llm_be/monetization/migrations/0004_subscriptionplan_allows_rag.py diff --git a/llm_be/monetization/migrations/0005_revenuecat_fields.py b/llm_be/monetization/migrations/0005_revenuecat_fields.py new file mode 100644 index 0000000..f4264ba --- /dev/null +++ b/llm_be/monetization/migrations/0005_revenuecat_fields.py @@ -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), + ), + ] diff --git a/llm_be/finance/migrations/__init__.py b/llm_be/monetization/migrations/__init__.py similarity index 100% rename from llm_be/finance/migrations/__init__.py rename to llm_be/monetization/migrations/__init__.py diff --git a/llm_be/finance/models.py b/llm_be/monetization/models.py similarity index 86% rename from llm_be/finance/models.py rename to llm_be/monetization/models.py index 47596f3..bb2b599 100644 --- a/llm_be/finance/models.py +++ b/llm_be/monetization/models.py @@ -30,6 +30,13 @@ class SubscriptionPlan(TimeInfoBase): default="", 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( default=False, help_text="Shown in public pricing / plan list APIs.", @@ -112,7 +119,7 @@ class BackerEmail(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): NONE = "none", "None" @@ -123,6 +130,7 @@ class UserSubscription(TimeInfoBase): class Source(models.TextChoices): NONE = "none", "None" STRIPE = "stripe", "Stripe" + REVENUECAT = "revenuecat", "RevenueCat" BACKER = "backer", "Backer" ADMIN = "admin", "Admin" @@ -155,14 +163,21 @@ class UserSubscription(TimeInfoBase): default="", 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( 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( null=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( null=True, @@ -191,10 +206,11 @@ class UserSubscription(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): STRIPE = "stripe", "Stripe" + REVENUECAT = "revenuecat", "RevenueCat" class Status(models.TextChoices): DRAFT = "draft", "Draft" @@ -259,6 +275,20 @@ class Invoice(TimeInfoBase): db_index=True, ) 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="") description = models.CharField(max_length=512, blank=True, default="") @@ -270,10 +300,11 @@ class Invoice(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): STRIPE = "stripe", "Stripe" + REVENUECAT = "revenuecat", "RevenueCat" class Status(models.TextChoices): PENDING = "pending", "Pending" @@ -331,6 +362,14 @@ class Payment(TimeInfoBase): unique=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) failure_message = models.CharField(max_length=512, blank=True, default="") diff --git a/llm_be/finance/serializers.py b/llm_be/monetization/serializers.py similarity index 93% rename from llm_be/finance/serializers.py rename to llm_be/monetization/serializers.py index bed2fd5..ad24beb 100644 --- a/llm_be/finance/serializers.py +++ b/llm_be/monetization/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from finance.models import Invoice, Payment, SubscriptionPlan +from monetization.models import Invoice, Payment, SubscriptionPlan class InvoiceSerializer(serializers.ModelSerializer): @@ -18,6 +18,8 @@ class InvoiceSerializer(serializers.ModelSerializer): "stripe_invoice_id", "stripe_checkout_session_id", "stripe_subscription_id", + "revenuecat_event_id", + "revenuecat_store", "hosted_invoice_url", "description", "created", @@ -38,6 +40,7 @@ class PaymentSerializer(serializers.ModelSerializer): "amount", "stripe_payment_intent_id", "stripe_charge_id", + "revenuecat_transaction_id", "paid_at", "failure_message", "created", diff --git a/llm_be/finance/services/__init__.py b/llm_be/monetization/services/__init__.py similarity index 100% rename from llm_be/finance/services/__init__.py rename to llm_be/monetization/services/__init__.py diff --git a/llm_be/finance/services/plans.py b/llm_be/monetization/services/plans.py similarity index 69% rename from llm_be/finance/services/plans.py rename to llm_be/monetization/services/plans.py index 9c792e2..406b433 100644 --- a/llm_be/finance/services/plans.py +++ b/llm_be/monetization/services/plans.py @@ -5,11 +5,12 @@ from __future__ import annotations import logging from typing import Any +from django.conf import settings from django.db import transaction from django.utils import timezone from chat_backend.models import UserAuthEvent -from finance.models import BackerEmail, SubscriptionPlan, UserSubscription +from monetization.models import BackerEmail, SubscriptionPlan, UserSubscription logger = logging.getLogger(__name__) @@ -162,6 +163,7 @@ def assign_plan( source: str, status: str = UserSubscription.Status.ACTIVE, stripe_subscription_id: str = "", + revenuecat_original_transaction_id: str = "", log_auth_event: bool = True, ) -> UserSubscription: sub = get_or_create_user_subscription(user) @@ -169,6 +171,7 @@ def assign_plan( prev_status = sub.status prev_source = sub.source prev_stripe_sub = sub.stripe_subscription_id or "" + prev_rc_txn = sub.revenuecat_original_transaction_id or "" had_active = ( prev_status == UserSubscription.Status.ACTIVE and prev_plan_id is not None ) @@ -178,6 +181,8 @@ def assign_plan( sub.status = status if 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() if log_auth_event: @@ -192,32 +197,30 @@ def assign_plan( bool(stripe_subscription_id) 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: log_subscription_auth_event( user, started=True, - detail=( - f"plan={plan.slug} source={source} status={status}" - + ( - f" stripe_subscription_id={stripe_subscription_id}" - if stripe_subscription_id - else "" - ) - ), + detail=f"plan={plan.slug} source={source} status={status}{extra}", ) elif changed: log_subscription_auth_event( user, started=False, - detail=( - f"plan={plan.slug} source={source} status={status}" - + ( - f" stripe_subscription_id={stripe_subscription_id}" - if stripe_subscription_id - else "" - ) - ), + detail=f"plan={plan.slug} source={source} status={status}{extra}", ) return sub @@ -243,6 +246,7 @@ def needs_checkout(user) -> bool: UserSubscription.Source.BACKER, UserSubscription.Source.ADMIN, UserSubscription.Source.STRIPE, + UserSubscription.Source.REVENUECAT, ): return False 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() +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: if plan is None: return None diff --git a/llm_be/finance/services/quotas.py b/llm_be/monetization/services/quotas.py similarity index 98% rename from llm_be/finance/services/quotas.py rename to llm_be/monetization/services/quotas.py index 70ae162..e91d311 100644 --- a/llm_be/finance/services/quotas.py +++ b/llm_be/monetization/services/quotas.py @@ -11,8 +11,8 @@ from django.db.models import Count, Q, Sum from django.utils import timezone from chat_backend.models import PromptMetric -from finance.models import UserSubscription -from finance.services.plans import get_or_create_user_subscription, seed_subscription_plans +from monetization.models import UserSubscription +from monetization.services.plans import get_or_create_user_subscription, seed_subscription_plans class QuotaExceeded(Exception): diff --git a/llm_be/monetization/services/revenuecat.py b/llm_be/monetization/services/revenuecat.py new file mode 100644 index 0000000..adaf37d --- /dev/null +++ b/llm_be/monetization/services/revenuecat.py @@ -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 `` (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) diff --git a/llm_be/finance/services/stripe_service.py b/llm_be/monetization/services/stripe.py similarity index 88% rename from llm_be/finance/services/stripe_service.py rename to llm_be/monetization/services/stripe.py index f6170ab..92db1ae 100644 --- a/llm_be/finance/services/stripe_service.py +++ b/llm_be/monetization/services/stripe.py @@ -1,4 +1,4 @@ -"""Stripe Checkout and Billing Portal session helpers.""" +"""Stripe Checkout, Billing Portal, and webhook dispatch.""" from __future__ import annotations @@ -7,8 +7,20 @@ from typing import Any import stripe from django.conf import settings -from finance.models import Invoice, SubscriptionPlan -from finance.services.plans import get_plan, seed_subscription_plans +from monetization.models import Invoice, SubscriptionPlan +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): diff --git a/llm_be/finance/services/webhooks.py b/llm_be/monetization/services/webhooks.py similarity index 99% rename from llm_be/finance/services/webhooks.py rename to llm_be/monetization/services/webhooks.py index 90be95f..e9ef678 100644 --- a/llm_be/finance/services/webhooks.py +++ b/llm_be/monetization/services/webhooks.py @@ -10,8 +10,8 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone -from finance.models import Invoice, Payment, UserSubscription -from finance.services.plans import ( +from monetization.models import Invoice, Payment, UserSubscription +from monetization.services.plans import ( assign_plan_from_stripe, get_or_create_user_subscription, log_subscription_auth_event, diff --git a/llm_be/finance/signals.py b/llm_be/monetization/signals.py similarity index 73% rename from llm_be/finance/signals.py rename to llm_be/monetization/signals.py index 1bb2132..5e64f7d 100644 --- a/llm_be/finance/signals.py +++ b/llm_be/monetization/signals.py @@ -3,6 +3,6 @@ def seed_plans_on_migrate(sender, **kwargs): """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) diff --git a/llm_be/finance/tests/__init__.py b/llm_be/monetization/tests/__init__.py similarity index 100% rename from llm_be/finance/tests/__init__.py rename to llm_be/monetization/tests/__init__.py diff --git a/llm_be/finance/tests/test_checkout.py b/llm_be/monetization/tests/test_checkout.py similarity index 96% rename from llm_be/finance/tests/test_checkout.py rename to llm_be/monetization/tests/test_checkout.py index 8562d2e..a75722a 100644 --- a/llm_be/finance/tests/test_checkout.py +++ b/llm_be/monetization/tests/test_checkout.py @@ -8,7 +8,7 @@ from rest_framework import status from rest_framework.test import APITestCase from chat_backend.tests.factories import make_company, make_user -from finance.models import Invoice +from monetization.models import Invoice class CreateCheckoutSessionViewTestCase(APITestCase): @@ -28,7 +28,7 @@ class CreateCheckoutSessionViewTestCase(APITestCase): STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok", 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): mock_session = MagicMock() mock_session.id = "cs_test_abc" @@ -78,7 +78,7 @@ class CreateCheckoutSessionViewTestCase(APITestCase): STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok", 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): mock_session = MagicMock() mock_session.id = "cs_test_price" diff --git a/llm_be/finance/tests/test_models.py b/llm_be/monetization/tests/test_models.py similarity index 97% rename from llm_be/finance/tests/test_models.py rename to llm_be/monetization/tests/test_models.py index dab10c3..65afba1 100644 --- a/llm_be/finance/tests/test_models.py +++ b/llm_be/monetization/tests/test_models.py @@ -4,7 +4,7 @@ from django.contrib import admin from django.test import TestCase 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): diff --git a/llm_be/finance/tests/test_plans_quotas.py b/llm_be/monetization/tests/test_plans_quotas.py similarity index 98% rename from llm_be/finance/tests/test_plans_quotas.py rename to llm_be/monetization/tests/test_plans_quotas.py index b25f9a0..1de971a 100644 --- a/llm_be/finance/tests/test_plans_quotas.py +++ b/llm_be/monetization/tests/test_plans_quotas.py @@ -11,14 +11,14 @@ from rest_framework.test import APITestCase from chat_backend.models import PromptMetric from chat_backend.tests.factories import make_company, make_conversation, make_user -from finance.models import BackerEmail, SubscriptionPlan, UserSubscription -from finance.services.plans import ( +from monetization.models import BackerEmail, SubscriptionPlan, UserSubscription +from monetization.services.plans import ( assign_plan, needs_checkout, seed_subscription_plans, try_redeem_backer_email, ) -from finance.services.quotas import ( +from monetization.services.quotas import ( FeatureNotAllowed, QuotaExceeded, assert_feature_allowed, @@ -227,7 +227,7 @@ class CheckoutUsesFoundersPlanTestCase(APITestCase): STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok", 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): mock_session = MagicMock() mock_session.id = "cs_test_founders" diff --git a/llm_be/finance/tests/test_portal.py b/llm_be/monetization/tests/test_portal.py similarity index 94% rename from llm_be/finance/tests/test_portal.py rename to llm_be/monetization/tests/test_portal.py index 6954c35..4c3d1c0 100644 --- a/llm_be/finance/tests/test_portal.py +++ b/llm_be/monetization/tests/test_portal.py @@ -8,7 +8,7 @@ from rest_framework import status from rest_framework.test import APITestCase from chat_backend.tests.factories import make_company, make_user -from finance.models import Invoice +from monetization.models import Invoice class CreateBillingPortalSessionViewTestCase(APITestCase): @@ -35,7 +35,7 @@ class CreateBillingPortalSessionViewTestCase(APITestCase): STRIPE_SECRET_KEY="sk_test_fake", 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): self._create_invoice_with_customer() mock_session = MagicMock() @@ -58,7 +58,7 @@ class CreateBillingPortalSessionViewTestCase(APITestCase): STRIPE_SECRET_KEY="sk_test_fake", 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): self._create_invoice_with_customer() mock_session = MagicMock() diff --git a/llm_be/monetization/tests/test_revenuecat.py b/llm_be/monetization/tests/test_revenuecat.py new file mode 100644 index 0000000..dc197d8 --- /dev/null +++ b/llm_be/monetization/tests/test_revenuecat.py @@ -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() + ) diff --git a/llm_be/finance/tests/test_webhooks.py b/llm_be/monetization/tests/test_webhooks.py similarity index 95% rename from llm_be/finance/tests/test_webhooks.py rename to llm_be/monetization/tests/test_webhooks.py index 9acc3bb..a38d8de 100644 --- a/llm_be/finance/tests/test_webhooks.py +++ b/llm_be/monetization/tests/test_webhooks.py @@ -9,9 +9,9 @@ from rest_framework.test import APITestCase from chat_backend.tests.factories import make_company, make_user from chat_backend.models import UserAuthEvent -from finance.models import Invoice, Payment, UserSubscription -from finance.services.plans import assign_plan_from_stripe, seed_subscription_plans -from finance.services.webhooks import ( +from monetization.models import Invoice, Payment, UserSubscription +from monetization.services.plans import assign_plan_from_stripe, seed_subscription_plans +from monetization.services.webhooks import ( dispatch_stripe_event, handle_checkout_session_completed, handle_customer_subscription_deleted, @@ -203,7 +203,7 @@ class StripeWebhookViewTestCase(APITestCase): self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) @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): import stripe @@ -219,8 +219,8 @@ class StripeWebhookViewTestCase(APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @override_settings(STRIPE_WEBHOOK_SECRET="whsec_test") - @patch("finance.views.dispatch_stripe_event") - @patch("finance.views.stripe.Webhook.construct_event") + @patch("monetization.views.dispatch_stripe_event") + @patch("monetization.views.stripe.Webhook.construct_event") def test_valid_event_dispatched(self, mock_construct, mock_dispatch): mock_construct.return_value = { "id": "evt_1", diff --git a/llm_be/finance/urls.py b/llm_be/monetization/urls.py similarity index 83% rename from llm_be/finance/urls.py rename to llm_be/monetization/urls.py index f475bcf..46868cd 100644 --- a/llm_be/finance/urls.py +++ b/llm_be/monetization/urls.py @@ -1,11 +1,12 @@ from django.urls import path -from finance.views import ( +from monetization.views import ( CreateBillingPortalSessionView, CreateCheckoutSessionView, InvoiceListView, PaymentListView, PlanListView, + RevenueCatWebhookView, StripeWebhookView, SubscriptionMeView, ) @@ -46,4 +47,9 @@ urlpatterns = [ StripeWebhookView.as_view(), name="finance_stripe_webhook", ), + path( + "webhooks/revenuecat/", + RevenueCatWebhookView.as_view(), + name="finance_revenuecat_webhook", + ), ] diff --git a/llm_be/finance/views.py b/llm_be/monetization/views.py similarity index 79% rename from llm_be/finance/views.py rename to llm_be/monetization/views.py index c6ae724..b068a9f 100644 --- a/llm_be/finance/views.py +++ b/llm_be/monetization/views.py @@ -6,27 +6,32 @@ from rest_framework import permissions, status from rest_framework.response import Response from rest_framework.views import APIView -from finance.models import Invoice, Payment, SubscriptionPlan, UserSubscription -from finance.serializers import ( +from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription +from monetization.serializers import ( CheckoutSessionSerializer, InvoiceSerializer, PaymentSerializer, PortalSessionSerializer, SubscriptionPlanSerializer, ) -from finance.services.plans import ( +from monetization.services.plans import ( needs_checkout, plan_to_dict, seed_subscription_plans, ) -from finance.services.quotas import get_usage_snapshot -from finance.services.stripe_service import ( +from monetization.services.quotas import get_usage_snapshot +from monetization.services.revenuecat import ( + RevenueCatWebhookAuthError, + dispatch_revenuecat_event, + verify_revenuecat_authorization, +) +from monetization.services.stripe import ( StripeNotConfiguredError, create_billing_portal_session, create_checkout_session, + dispatch_stripe_event, resolve_stripe_customer_id, ) -from finance.services.webhooks import dispatch_stripe_event logger = logging.getLogger(__name__) @@ -199,6 +204,9 @@ class SubscriptionMeView(APIView): "stripe_subscription_id": ( 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, "current_period_end": ( sub.current_period_end.isoformat() @@ -258,3 +266,50 @@ class StripeWebhookView(APIView): ) return Response({"received": True}, status=status.HTTP_200_OK) + + +class RevenueCatWebhookView(APIView): + """Verify RevenueCat Authorization and upsert subscription + ledger rows.""" + + permission_classes = (permissions.AllowAny,) + authentication_classes = () + + def post(self, request): + webhook_secret = settings.REVENUECAT_WEBHOOK_SECRET + if not webhook_secret: + logger.error("REVENUECAT_WEBHOOK_SECRET is not configured") + return Response( + {"detail": "Webhook secret not configured"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + try: + verify_revenuecat_authorization( + authorization_header=request.META.get("HTTP_AUTHORIZATION"), + expected_secret=webhook_secret, + ) + except RevenueCatWebhookAuthError as exc: + return Response( + {"detail": str(exc)}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + payload = request.data + if not isinstance(payload, dict): + return Response( + {"detail": "Invalid payload"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + dispatch_revenuecat_event(payload) + except Exception: + event = payload.get("event") if isinstance(payload, dict) else {} + event_id = event.get("id") if isinstance(event, dict) else None + logger.exception("Error handling RevenueCat event %s", event_id) + return Response( + {"detail": "Webhook handler error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response({"received": True}, status=status.HTTP_200_OK)