Compare commits

...
2 Commits
Author SHA1 Message Date
westfarn 29c69b91f7 Add monetization app with RevenueCat webhooks alongside Stripe.
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s
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.
2026-08-04 03:32:51 -07:00
westfarn 2aeb95136a Add PromptFeedback API for per-message thumbs ratings (#67) (#70)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 11s
Deploy Beta / docker (push) Successful in 22s
Deploy Beta / deploy-beta (push) Successful in 49s
## Summary
- Closes [#67](#67) — new `PromptFeedback` model (unique on `(prompt, user)`) with `rating` (`up`|`down`), optional `reason` / `comment`, and timestamps via `TimeInfoBase`.
- `POST /api/prompt_feedback` upserts `{ prompt_id, rating, reason?, comment? }`; `DELETE /api/prompt_feedback?prompt_id=` clears the caller's vote.
- `GET conversation_details` now nests the caller's `feedback: { rating, reason, comment }` (or `null`) on each prompt so [chat_web_app#101](ai_ml_operations/chat_web_app#101) can rehydrate thumbs UI.
- Auth required; users can only rate assistant prompts in their own non-deleted conversations. Distinct from app-wide `POST /feedbacks/`.
- Joinable to `PromptMetric` via `prompt_id` for per-model accuracy slices.

## Test plan
- [ ] `uv run python manage.py test chat_backend.tests.test_views_prompt_feedback`
- [ ] Upsert thumbs up, then down with reason/comment — one row updated
- [ ] DELETE clears vote; second DELETE → 404
- [ ] Rating another user's prompt → 404; rating a user message → 400
- [ ] Reload conversation details → assistant prompts show caller feedback only
- [ ] Companion FE [chat_web_app#101](ai_ml_operations/chat_web_app#101) thumbs + reason popover against this APIReviewed-on: #70
2026-08-04 03:23:58 -07:00
49 changed files with 1444 additions and 107 deletions
+5 -1
View File
@@ -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
View File
@@ -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=
+14 -2
View File
@@ -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.
+10
View File
@@ -8,6 +8,7 @@ from .models import (
Conversation, Conversation,
Prompt, Prompt,
Feedback, Feedback,
PromptFeedback,
PromptMetric, PromptMetric,
DocumentWorkspace, DocumentWorkspace,
Document, Document,
@@ -134,6 +135,14 @@ class FeedbackAdmin(admin.ModelAdmin):
list_display = ("status", "get_user_email", "title", "category") list_display = ("status", "get_user_email", "title", "category")
class PromptFeedbackAdmin(admin.ModelAdmin):
model = PromptFeedback
list_display = ("id", "prompt", "user", "rating", "reason", "created")
list_filter = ("rating", "reason")
search_fields = ("user__email", "comment", "prompt__message")
raw_id_fields = ("prompt", "user")
class LLMModelsAdmin(admin.ModelAdmin): class LLMModelsAdmin(admin.ModelAdmin):
model = LLMModels model = LLMModels
list_display = ("name", "port", "description") list_display = ("name", "port", "description")
@@ -259,6 +268,7 @@ admin.site.register(Conversation, ConversationAdmin)
admin.site.register(Prompt, PromptAdmin) admin.site.register(Prompt, PromptAdmin)
admin.site.register(PromptMetric, PromptMetricAdmin) admin.site.register(PromptMetric, PromptMetricAdmin)
admin.site.register(Feedback, FeedbackAdmin) admin.site.register(Feedback, FeedbackAdmin)
admin.site.register(PromptFeedback, PromptFeedbackAdmin)
admin.site.register(DocumentWorkspace, DocumentWorkspaceAdmin) admin.site.register(DocumentWorkspace, DocumentWorkspaceAdmin)
admin.site.register(Document, DocumentAdmin) admin.site.register(Document, DocumentAdmin)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
@@ -0,0 +1,87 @@
# Generated manually for chat_backend#67
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat_backend", "0031_prompt_citations"),
]
operations = [
migrations.CreateModel(
name="PromptFeedback",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(default=django.utils.timezone.now)),
(
"last_modified",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"rating",
models.CharField(
choices=[("up", "Up"), ("down", "Down")], max_length=8
),
),
(
"reason",
models.CharField(
blank=True,
choices=[
("incorrect", "Incorrect"),
("out_of_date", "Out of date"),
(
"didnt_follow_instructions",
"Didn't follow instructions",
),
("unsafe", "Unsafe"),
("other", "Other"),
],
max_length=64,
null=True,
),
),
(
"comment",
models.TextField(blank=True, max_length=1024, null=True),
),
(
"prompt",
models.ForeignKey(
help_text="Assistant prompt being rated",
on_delete=django.db.models.deletion.CASCADE,
related_name="prompt_feedbacks",
to="chat_backend.prompt",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="prompt_feedbacks",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="promptfeedback",
constraint=models.UniqueConstraint(
fields=("prompt", "user"),
name="uniq_prompt_feedback_prompt_user",
),
),
]
+53
View File
@@ -329,6 +329,59 @@ class Prompt(TimeInfoBase):
return self.file != None and self.file.storage.exists(self.file.name) return self.file != None and self.file.storage.exists(self.file.name)
class PromptFeedback(TimeInfoBase):
"""Per-message thumbs rating for an assistant Prompt (chat_backend#67).
Distinct from app-wide ``Feedback`` (product bugs). Joinable to
``PromptMetric`` via ``prompt_id`` for per-model accuracy slices.
"""
class Rating(models.TextChoices):
UP = "up", "Up"
DOWN = "down", "Down"
class Reason(models.TextChoices):
INCORRECT = "incorrect", "Incorrect"
OUT_OF_DATE = "out_of_date", "Out of date"
DIDNT_FOLLOW_INSTRUCTIONS = (
"didnt_follow_instructions",
"Didn't follow instructions",
)
UNSAFE = "unsafe", "Unsafe"
OTHER = "other", "Other"
prompt = models.ForeignKey(
Prompt,
on_delete=models.CASCADE,
related_name="prompt_feedbacks",
help_text="Assistant prompt being rated",
)
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name="prompt_feedbacks",
)
rating = models.CharField(max_length=8, choices=Rating.choices)
reason = models.CharField(
max_length=64,
choices=Reason.choices,
blank=True,
null=True,
)
comment = models.TextField(max_length=1024, blank=True, null=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("prompt", "user"),
name="uniq_prompt_feedback_prompt_user",
)
]
def __str__(self):
return f"PromptFeedback(prompt={self.prompt_id}, user={self.user_id}, {self.rating})"
class PromptMetric(TimeInfoBase): class PromptMetric(TimeInfoBase):
PROMPT_METRIC_CHOICES = ( PROMPT_METRIC_CHOICES = (
("CREATED", "Created"), ("CREATED", "Created"),
+1 -1
View File
@@ -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",
+77 -4
View File
@@ -8,6 +8,7 @@ from .models import (
Company, Company,
Conversation, Conversation,
Prompt, Prompt,
PromptFeedback,
PromptMetric, PromptMetric,
Feedback, Feedback,
FEEDBACK_CATEGORIES, FEEDBACK_CATEGORIES,
@@ -71,8 +72,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 +122,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"]
@@ -210,9 +211,52 @@ class ConversationSerializer(serializers.ModelSerializer):
return tout return tout
class PromptFeedbackSerializer(serializers.ModelSerializer):
prompt_id = serializers.IntegerField(source="prompt.id", read_only=True)
class Meta:
model = PromptFeedback
fields = (
"id",
"prompt_id",
"rating",
"reason",
"comment",
"created",
"last_modified",
)
read_only_fields = ("id", "prompt_id", "created", "last_modified")
class PromptFeedbackUpsertSerializer(serializers.Serializer):
prompt_id = serializers.IntegerField()
rating = serializers.ChoiceField(choices=PromptFeedback.Rating.choices)
reason = serializers.ChoiceField(
choices=PromptFeedback.Reason.choices,
required=False,
allow_null=True,
allow_blank=True,
)
comment = serializers.CharField(
required=False, allow_null=True, allow_blank=True, max_length=1024
)
def validate_reason(self, value):
if value == "":
return None
return value
def validate_comment(self, value):
if value is None:
return None
stripped = str(value).strip()
return stripped or None
class PromptSerializer(serializers.ModelSerializer): class PromptSerializer(serializers.ModelSerializer):
tokens_in = serializers.SerializerMethodField() tokens_in = serializers.SerializerMethodField()
tokens_out = serializers.SerializerMethodField() tokens_out = serializers.SerializerMethodField()
feedback = serializers.SerializerMethodField()
class Meta: class Meta:
model = Prompt model = Prompt
@@ -224,8 +268,9 @@ class PromptSerializer(serializers.ModelSerializer):
"tokens_in", "tokens_in",
"tokens_out", "tokens_out",
"citations", "citations",
"feedback",
) )
read_only_fields = ("citations",) read_only_fields = ("citations", "feedback")
def _token_pair(self, obj): def _token_pair(self, obj):
cache = self.context.setdefault("_prompt_token_cache", {}) cache = self.context.setdefault("_prompt_token_cache", {})
@@ -241,6 +286,34 @@ class PromptSerializer(serializers.ModelSerializer):
_, tout = self._token_pair(obj) _, tout = self._token_pair(obj)
return tout return tout
def get_feedback(self, obj):
"""Current caller's rating for this prompt, if any."""
request = self.context.get("request")
if request is None or not getattr(request, "user", None):
return None
user = request.user
if not user.is_authenticated:
return None
by_prompt = self.context.get("_prompt_feedback_by_id")
if by_prompt is None:
prompt_ids = self.context.get("_prompt_ids_for_feedback")
qs = PromptFeedback.objects.filter(user=user).only(
"prompt_id", "rating", "reason", "comment"
)
if prompt_ids is not None:
qs = qs.filter(prompt_id__in=prompt_ids)
by_prompt = {
row.prompt_id: {
"rating": row.rating,
"reason": row.reason,
"comment": row.comment,
}
for row in qs
}
self.context["_prompt_feedback_by_id"] = by_prompt
return by_prompt.get(obj.id)
def validate_message(self, value: str) -> str: def validate_message(self, value: str) -> str:
if value is None or not str(value).strip(): if value is None or not str(value).strip():
raise serializers.ValidationError("Message text cannot be empty.") raise serializers.ValidationError("Message text cannot be empty.")
+4 -4
View File
@@ -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")
+4 -4
View File
@@ -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
@@ -0,0 +1,149 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.models import PromptFeedback, PromptMetric
from .factories import make_company, make_conversation, make_prompt, make_user
class PromptFeedbackViewTestCase(APITestCase):
def setUp(self):
self.user = make_user(company=make_company())
self.client.force_authenticate(user=self.user)
self.conversation = make_conversation(user=self.user)
self.assistant = make_prompt(
self.conversation, message="answer", user_created=False
)
self.user_prompt = make_prompt(
self.conversation, message="question", user_created=True
)
self.url = reverse("prompt_feedback")
self.details_url = reverse("conversation_details")
def test_upsert_creates_unique_row(self):
response = self.client.post(
self.url,
{"prompt_id": self.assistant.id, "rating": "up"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["rating"], "up")
self.assertEqual(response.data["prompt_id"], self.assistant.id)
self.assertEqual(PromptFeedback.objects.count(), 1)
again = self.client.post(
self.url,
{
"prompt_id": self.assistant.id,
"rating": "down",
"reason": "incorrect",
"comment": "wrong cite",
},
format="json",
)
self.assertEqual(again.status_code, status.HTTP_200_OK)
self.assertEqual(PromptFeedback.objects.count(), 1)
row = PromptFeedback.objects.get()
self.assertEqual(row.rating, "down")
self.assertEqual(row.reason, "incorrect")
self.assertEqual(row.comment, "wrong cite")
def test_delete_clears_vote(self):
PromptFeedback.objects.create(
prompt=self.assistant, user=self.user, rating="up"
)
response = self.client.delete(
f"{self.url}?prompt_id={self.assistant.id}"
)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(PromptFeedback.objects.count(), 0)
def test_delete_missing_vote_is_404(self):
response = self.client.delete(
f"{self.url}?prompt_id={self.assistant.id}"
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_cannot_rate_user_prompt(self):
response = self.client.post(
self.url,
{"prompt_id": self.user_prompt.id, "rating": "up"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(PromptFeedback.objects.count(), 0)
def test_cannot_rate_other_users_prompt(self):
other = make_user(email="other@example.com", company=make_company("O"))
foreign = make_prompt(
make_conversation(user=other), message="secret", user_created=False
)
response = self.client.post(
self.url,
{"prompt_id": foreign.id, "rating": "up"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(PromptFeedback.objects.count(), 0)
def test_conversation_details_includes_caller_feedback(self):
PromptFeedback.objects.create(
prompt=self.assistant,
user=self.user,
rating="down",
reason="unsafe",
comment="bad",
)
# Another user's vote must not leak
other = make_user(email="peer@example.com", company=self.user.company)
PromptFeedback.objects.create(
prompt=self.assistant, user=other, rating="up"
)
response = self.client.get(
self.details_url, {"conversation_id": self.conversation.id}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
by_id = {item["id"]: item for item in response.data}
self.assertIsNone(by_id[self.user_prompt.id]["feedback"])
self.assertEqual(
by_id[self.assistant.id]["feedback"],
{"rating": "down", "reason": "unsafe", "comment": "bad"},
)
def test_feedback_joinable_to_prompt_metric(self):
PromptFeedback.objects.create(
prompt=self.assistant, user=self.user, rating="up"
)
PromptMetric.objects.create(
prompt_id=self.assistant.id,
conversation_id=self.conversation.id,
event="FINISHED",
model_name="llama3.2",
start_time=self.assistant.created,
prompt_length=10,
has_file=False,
)
joined = PromptFeedback.objects.filter(
prompt_id__in=PromptMetric.objects.filter(
model_name="llama3.2"
).values_list("prompt_id", flat=True)
)
self.assertEqual(joined.count(), 1)
def test_unauthenticated_rejected(self):
self.client.force_authenticate(user=None)
response = self.client.post(
self.url,
{"prompt_id": self.assistant.id, "rating": "up"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+6
View File
@@ -12,6 +12,7 @@ from .views import (
is_authenticated, is_authenticated,
AnnouncmentView, AnnouncmentView,
FeedbackView, FeedbackView,
PromptFeedbackView,
ConversationsView, ConversationsView,
ConversationDetailView, ConversationDetailView,
CompanyUsersView, CompanyUsersView,
@@ -78,6 +79,11 @@ urlpatterns = [
path("announcment/get/", AnnouncmentView.as_view(), name="get_announcments"), path("announcment/get/", AnnouncmentView.as_view(), name="get_announcments"),
path("conversations", ConversationsView.as_view(), name="conversations"), path("conversations", ConversationsView.as_view(), name="conversations"),
path("feedbacks/", FeedbackView.as_view(), name="feedbacks"), path("feedbacks/", FeedbackView.as_view(), name="feedbacks"),
path(
"prompt_feedback",
PromptFeedbackView.as_view(),
name="prompt_feedback",
),
path( path(
"conversation_details", "conversation_details",
ConversationDetailView.as_view(), ConversationDetailView.as_view(),
+102 -6
View File
@@ -13,6 +13,8 @@ from .serializers import (
ConversationSerializer, ConversationSerializer,
PromptSerializer, PromptSerializer,
FeedbackSerializer, FeedbackSerializer,
PromptFeedbackSerializer,
PromptFeedbackUpsertSerializer,
DocumentWorkspaceSerializer, DocumentWorkspaceSerializer,
DocumentSerializer, DocumentSerializer,
) )
@@ -24,6 +26,7 @@ from .models import (
Announcement, Announcement,
Conversation, Conversation,
Prompt, Prompt,
PromptFeedback,
Feedback, Feedback,
PromptMetric, PromptMetric,
DocumentWorkspace, DocumentWorkspace,
@@ -66,7 +69,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 +146,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(
{ {
@@ -380,6 +383,90 @@ class FeedbackView(APIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def _user_can_rate_prompt(user, prompt: Prompt) -> bool:
"""Caller may rate prompts in their own non-deleted conversations."""
conversation = prompt.conversation
return (
conversation is not None
and conversation.user_id == user.id
and not conversation.deleted
)
class PromptFeedbackView(APIView):
"""Upsert / clear per-message thumbs ratings (chat_backend#67)."""
http_method_names = ["post", "delete"]
def post(self, request, format="json"):
serializer = PromptFeedbackUpsertSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
prompt_id = serializer.validated_data["prompt_id"]
try:
prompt = Prompt.objects.select_related("conversation").get(id=prompt_id)
except Prompt.DoesNotExist:
return Response(
{"detail": "Prompt not found."},
status=status.HTTP_404_NOT_FOUND,
)
if not _user_can_rate_prompt(request.user, prompt):
return Response(
{"detail": "Prompt not found."},
status=status.HTTP_404_NOT_FOUND,
)
if prompt.user_created:
return Response(
{"detail": "Only assistant prompts can be rated."},
status=status.HTTP_400_BAD_REQUEST,
)
feedback, _created = PromptFeedback.objects.update_or_create(
prompt=prompt,
user=request.user,
defaults={
"rating": serializer.validated_data["rating"],
"reason": serializer.validated_data.get("reason"),
"comment": serializer.validated_data.get("comment"),
},
)
return Response(
PromptFeedbackSerializer(feedback).data,
status=status.HTTP_200_OK,
)
def delete(self, request, format="json"):
prompt_id = request.query_params.get("prompt_id")
if prompt_id is None:
return Response(
{"detail": "prompt_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
prompt_id = int(prompt_id)
except (TypeError, ValueError):
return Response(
{"detail": "prompt_id must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
deleted, _ = PromptFeedback.objects.filter(
prompt_id=prompt_id,
user=request.user,
prompt__conversation__user=request.user,
prompt__conversation__deleted=False,
).delete()
if not deleted:
return Response(
{"detail": "Prompt feedback not found."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(status=status.HTTP_204_NO_CONTENT)
class AcknowledgeTermsOfService(APIView): class AcknowledgeTermsOfService(APIView):
http_method_names = ["post"] http_method_names = ["post"]
@@ -516,11 +603,20 @@ class ConversationDetailView(APIView):
{"detail": "Conversation not found."}, {"detail": "Conversation not found."},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
prompts = Prompt.objects.filter( prompts = list(
conversation__id=conversation_id, conversation__user=request.user Prompt.objects.filter(
conversation__id=conversation_id, conversation__user=request.user
)
) )
serailzer = PromptSerializer(prompts, many=True) serializer = PromptSerializer(
return Response(serailzer.data, status=status.HTTP_200_OK) prompts,
many=True,
context={
"request": request,
"_prompt_ids_for_feedback": [p.id for p in prompts],
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, format="json"): def post(self, request, format="json"):
logger.info("In the post") logger.info("In the post")
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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(
-14
View File
@@ -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)
+18 -2
View File
@@ -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"
+2 -1
View File
@@ -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)
+22
View File
@@ -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):
+319
View File
@@ -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):
@@ -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)