Compare commits

..
1 Commits
Author SHA1 Message Date
westfarnandCursor 3ef49d1d15 Add public waitlist and usage counts for the marketing site.
CI / test (pull_request) Failing after 7s
Unit Tests / test (pull_request) Failing after 5s
Counts stay aggregate, and joining the waitlist does not create an account.
Closes #74.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 05:39:21 -07:00
41 changed files with 228 additions and 1932 deletions

No files matched your search

+4 -13
View File
@@ -16,8 +16,6 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
# OLLAMA_MODEL=gpt-oss:20b
# OLLAMA_MODEL_THINKING=gpt-oss:20b
# OLLAMA_MODEL_FAST=gemma4:latest
# Vision (image + prompt). Must be a vision-capable tag. Default gemma4:latest.
# OLLAMA_MODEL_VISION=gemma4:latest
# OLLAMA_MODEL_UTILITY=llama3.2
# OLLAMA_EMBED_MODEL=nomic-embed-text
# OLLAMA_NUM_CTX_THINKING=16384
@@ -41,11 +39,8 @@ EMAIL_USE_TLS=true
# Captcha (optional local)
CAPTCHA_SECRET_KEY=
# Password self-serve sign-up (default true). Set false to close /user/create/.
ENABLE_ACCOUNT_REGISTRATION=true
# Browser SSO. Leave false until Google / Microsoft sign-in should be public.
# Client ids below can still be set for Drive linking.
ENABLE_SSO=false
# Self-serve sign-up (default false — set true to allow /user/create/)
ENABLE_ACCOUNT_REGISTRATION=false
# OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons.
# Redirect URIs (register in each IdP console):
@@ -88,12 +83,8 @@ 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=
# Native store purchases (#78). iOS verifies the StoreKit signed transaction
# against Apple Root CA G3. Android calls the Play Developer API.
APPLE_IAP_BUNDLE_ID=ai.hesychia.chat
GOOGLE_PLAY_PACKAGE_NAME=ai.hesychia.chat
# Service-account JSON (one line) or a path to the JSON file. Empty disables Play confirm.
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=
# 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
+4 -12
View File
@@ -37,8 +37,6 @@ OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=gpt-oss:20b
OLLAMA_MODEL_THINKING=gpt-oss:20b
OLLAMA_MODEL_FAST=gemma4:latest
# Vision (image + prompt). Must be a vision-capable tag.
OLLAMA_MODEL_VISION=gemma4:latest
OLLAMA_MODEL_UTILITY=llama3.2
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_NUM_CTX_THINKING=16384
@@ -61,13 +59,9 @@ EMAIL_USE_TLS=true
# Captcha
CAPTCHA_SECRET_KEY=replace-with-captcha-secret
# Password self-serve sign-up. Default true. Set false in
# chat_backend_prod.env / chat_backend_beta.env to close public registration.
# An explicit false in that secret overrides the code default.
ENABLE_ACCOUNT_REGISTRATION=true
# Browser SSO stays off. OAuth client ids below are for Drive linking until
# this is set true.
ENABLE_SSO=false
# Self-serve account registration (sign-up). Keep false until ready to open
# public registration; set true in chat_backend_prod.env / chat_backend_beta.env.
ENABLE_ACCOUNT_REGISTRATION=false
# OAuth SSO — Google / Microsoft (#24). Never commit real secrets.
# Register redirect URIs:
@@ -97,9 +91,7 @@ OAUTH_CALLBACK_BASE_URL=https://chatbackend.aimloperations.com
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
STRIPE_WEBHOOK_SECRET=replace-with-stripe-webhook-secret
APPLE_IAP_BUNDLE_ID=ai.hesychia.chat
GOOGLE_PLAY_PACKAGE_NAME=ai.hesychia.chat
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=replace-with-play-console-service-account-json
REVENUECAT_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).
+1 -2
View File
@@ -9,8 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libpq5 \
&& update-ca-certificates \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
+6 -13
View File
@@ -97,12 +97,9 @@ with `COMPOSE_DATABASE_URL` if needed.
| `SEARXNG_BASE_URL` | `http://127.0.0.1:8088` | yes if using SearxNG | Self-hosted SearxNG JSON API |
| `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO |
| `CAPTCHA_SECRET_KEY` | empty | recommended | |
| `ENABLE_ACCOUNT_REGISTRATION` | `true` | optional | Password self-serve sign-up. Set `false` to close it |
| `ENABLE_SSO` | `false` | optional | Google / Microsoft sign-in and sign-up. Drive linking does not need this |
| `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready |
| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook (`monetization` app) |
| `APPLE_IAP_BUNDLE_ID` | `ai.hesychia.chat` | yes for iOS IAP | Must match the signed StoreKit transaction |
| `GOOGLE_PLAY_PACKAGE_NAME` | `ai.hesychia.chat` | yes for Play IAP | Android application id |
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | empty | yes for Play IAP | Play Console service-account JSON (or a file path) |
| `REVENUECAT_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 |
@@ -289,14 +286,10 @@ URLs: `/api/monetization/...` and alias `/api/finance/...`.
(`POST /api/finance/portal/`). Local state syncs via
`customer.subscription.updated` / `deleted` webhooks.
**Native (Play / App Store):** the app buys through StoreKit or Play Billing,
then `POST /api/finance/store/confirm/` with the signed App Store transaction
or the Play purchase token. The backend verifies it (Apple certificate chain,
or Play Developer API `purchases.subscriptionsv2`) and upserts the ledger.
`source` is `app_store` or `play_store`. There is no store webhook. Renewals
are another confirm from the app; access ends when `current_period_end` passes.
`GET /api/finance/subscription/` includes `store_account_token` to pass into
the store purchase as `appAccountToken` / `obfuscatedAccountId`.
**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.
+27 -34
View File
@@ -1,4 +1,5 @@
import json
import base64
import logging
import pandas as pd
from datetime import datetime
@@ -13,7 +14,6 @@ from langchain_core.messages import HumanMessage, AIMessage
from langchain_community.vectorstores import Chroma
from langchain_ollama import OllamaEmbeddings
from chat_backend.ollama_config import (
ROLE_VISION,
ollama_embeddings_kwargs,
ollama_model_for_role,
resolve_chat_role,
@@ -38,7 +38,6 @@ from .services.title_generator import title_generator
from .services.moderation_classifier import moderation_classifier, ModerationLabel
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
from .services.data_analysis_service import AsyncDataAnalysisService
from .services.vision import AsyncVisionService, is_image_type, prepare_attachment
from .services.grounded_chat import prepare_grounded_chat
from .services.status_context import (
emit_status,
@@ -143,10 +142,7 @@ def get_messages(conversation_id, prompt, file_string: str = None, file_type: st
prompt_instance.file_type = file_type
prompt_instance.save()
for prompt_obj in (
Prompt.objects.filter(conversation__id=conversation_id)
.order_by("created", "id")
):
for prompt_obj in Prompt.objects.filter(conversation__id=conversation_id):
messages.append(
{
"content": prompt_obj.message,
@@ -282,9 +278,7 @@ async def get_conversation_file_async(conversation_id):
# Get the very first prompt in the conversation that has a file
prompt_with_file = await Prompt.objects.filter(
conversation_id=conversation_id
).exclude(file='').exclude(
file_type__in=["png", "jpeg", "jpg", "webp", "gif"]
).order_by('created').afirst()
).exclude(file='').order_by('created').afirst()
if prompt_with_file and prompt_with_file.file:
# Opening a DatabaseStorage file hits the DB, so read inside the thread.
@@ -420,19 +414,29 @@ class ChatConsumerAgain(AsyncWebsocketConsumer):
decoded_file = None
if file:
decoded_file, file_type, attachment_error = prepare_attachment(
file, file_type
)
if attachment_error:
await self.send_json_message(
json.dumps(
{
"type": "error",
"content": attachment_error,
}
)
)
return
decoded_file = base64.b64decode(file)
logger.debug(decoded_file)
# The `altered_message` should only be created if a file exists
# and you want to pass its content directly to the classifier.
# Here, we'll let the classifier decide based on the user's prompt
# and then handle the file content separately.
altered_message = message
if "csv" in file_type:
file_type = "csv"
#altered_message = f"{message}\n The file type is csv and the file contents are: {decoded_file}"
elif "xmlformats-officedocument" in file_type:
file_type = "xlsx"
#df = pd.read_excel(decoded_file)
#altered_message = f"{message}\n The file type is xlsx and the file contents are: {df}"
elif "word" in file_type:
file_type = "docx"
elif "pdf" in file_type:
file_type = "pdf"
elif "text" in file_type:
file_type = "txt"
#altered_message = f"{message}\n The file type is txt and the file contents are: {decoded_file}"
else:
file_type = "Not Sure"
logger.info(f'received: "{message}" for conversation {conversation_id}')
@@ -472,15 +476,6 @@ class ChatConsumerAgain(AsyncWebsocketConsumer):
decoded_file = input_dict.get("decoded_file")
file_type = input_dict.get("file_type")
if is_image_type(file_type) and decoded_file:
service = AsyncVisionService()
input_dict["_resolved_model"] = service.model_name
input_dict["_citations"] = []
await emit_status("reading_image")
return service.generate_response(
prompt_instance.message, decoded_file, file_type
)
# Feature Flag + plan gate: Image Generation
if prompt_type == PromptType.IMAGE_GENERATION:
if not getattr(settings, "ALLOW_IMAGE_GENERATION", False):
@@ -608,9 +603,7 @@ class ChatConsumerAgain(AsyncWebsocketConsumer):
# messages = messages[:-1] + [HumanMessage(content=altered_message)]
# I'll add it to the input_dict if needed.
resolved_model = ollama_model_for_role(
ROLE_VISION if is_image_type(file_type) else resolve_chat_role(model)
)
resolved_model = ollama_model_for_role(resolve_chat_role(model))
prompt_metric = await create_prompt_metric(
prompt_instance.id,
prompt_instance.message,
+13 -45
View File
@@ -1,4 +1,5 @@
import json
import base64
import logging
import pandas as pd
from datetime import datetime
@@ -28,7 +29,6 @@ from .services.title_generator import title_generator
from .services.moderation_classifier import moderation_classifier, ModerationLabel
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
from .services.data_analysis_service import AsyncDataAnalysisService
from .services.vision import AsyncVisionService, is_image_type, prepare_attachment
from .services.grounded_chat import prepare_grounded_chat
from .services.status_context import (
emit_status,
@@ -36,11 +36,7 @@ from .services.status_context import (
set_status_emitter,
)
from .services.ws_frames import citations_frame, status_frame
from chat_backend.ollama_config import (
ROLE_VISION,
ollama_model_for_role,
resolve_chat_role,
)
from chat_backend.ollama_config import ollama_model_for_role, resolve_chat_role
from .utils import (
TokenUsageCollector,
aiter_text_chunks,
@@ -126,10 +122,7 @@ def get_messages(conversation_id, prompt, file_string: str = None, file_type: st
prompt_instance.file_type = file_type
prompt_instance.save()
for prompt_obj in (
Prompt.objects.filter(conversation__id=conversation_id)
.order_by("created", "id")
):
for prompt_obj in Prompt.objects.filter(conversation__id=conversation_id):
messages.append(
{
"content": prompt_obj.message,
@@ -209,9 +202,7 @@ async def get_conversation_file_async(conversation_id):
try:
prompt_with_file = await Prompt.objects.filter(
conversation_id=conversation_id
).exclude(file='').exclude(
file_type__in=["png", "jpeg", "jpg", "webp", "gif"]
).order_by('created').afirst()
).exclude(file='').order_by('created').afirst()
if prompt_with_file and prompt_with_file.file:
# Opening a DatabaseStorage file hits the DB, so read inside the thread.
@@ -259,10 +250,6 @@ async def classification_node(state: ChatState) -> ChatState:
prompt_type = await PROMPT_CLASSIFIER.classify_async(msg)
# Images are read by the vision model, not the spreadsheet parser.
if is_image_type(state.get("file_type")):
return {"prompt_type": PromptType.GENERAL_CHAT}
# Override logic
if decoded_file and (prompt_type == PromptType.DATA_ANALYSIS or 'analyze' in msg.lower() or 'data' in msg.lower()):
prompt_type = PromptType.DATA_ANALYSIS
@@ -283,17 +270,6 @@ async def generation_node(state: ChatState) -> ChatState:
decoded_file = state.get("decoded_file")
file_type = state.get("file_type")
if is_image_type(file_type) and decoded_file:
service = AsyncVisionService()
await emit_status("reading_image")
generator = service.generate_response(
prompt_instance.message, decoded_file, file_type
)
return {
"response_generator": generator,
"resolved_model": service.model_name,
}
# Feature Flag + plan gate: Image Generation
if prompt_type == PromptType.IMAGE_GENERATION:
if not getattr(settings, "ALLOW_IMAGE_GENERATION", False):
@@ -509,19 +485,13 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
print("Conversation ID: ", conversation_id)
decoded_file = None
if file:
decoded_file, file_type, attachment_error = prepare_attachment(
file, file_type
)
if attachment_error:
await self.send_json_message(
json.dumps(
{
"type": "error",
"content": attachment_error,
}
)
)
return
decoded_file = base64.b64decode(file)
if "csv" in file_type: file_type = "csv"
elif "xmlformats-officedocument" in file_type: file_type = "xlsx"
elif "word" in file_type: file_type = "docx"
elif "pdf" in file_type: file_type = "pdf"
elif "text" in file_type: file_type = "txt"
else: file_type = "Not Sure"
# Pre-fetch messages and file
messages, prompt_instance = await get_messages(
@@ -531,9 +501,7 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
if not decoded_file:
decoded_file, file_type = await get_conversation_file_async(conversation_id)
resolved_model = ollama_model_for_role(
ROLE_VISION if is_image_type(file_type) else resolve_chat_role(model)
)
resolved_model = ollama_model_for_role(resolve_chat_role(model))
prompt_metric = await create_prompt_metric(
prompt_instance.id,
prompt_instance.message,
@@ -588,7 +556,7 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
citations = []
final_model = resolved_model
if should_use_agent(message) and not is_image_type(file_type):
if should_use_agent(message):
try:
await enforce_feature_gate(chat_user, "agentic_tasks")
except FeatureNotAllowed as exc:
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat_backend", "0034_customuser_use_conversation_context"),
("chat_backend", "0033_agentrun_agentstep"),
]
operations = [
@@ -1,36 +0,0 @@
from django.db import migrations, models
def enable_same_chat_history(apps, schema_editor):
"""Turn on thread history for accounts still on the old default (#84).
The column defaulted to false, so an explicit opt-out cannot be told apart
from "never touched". Follow-ups were ignoring the open chat. Accounts that
still want one-shot answers can turn the setting off again.
"""
CustomUser = apps.get_model("chat_backend", "CustomUser")
CustomUser.objects.filter(use_conversation_context=False).update(
use_conversation_context=True
)
class Migration(migrations.Migration):
dependencies = [
("chat_backend", "0035_waitlistentry"),
]
operations = [
migrations.AlterField(
model_name="customuser",
name="use_conversation_context",
field=models.BooleanField(
default=True,
help_text=(
"When enabled, earlier turns in the open chat are included "
"when answering a follow-up. Turn off to answer each message alone."
),
),
),
migrations.RunPython(enable_same_chat_history, migrations.RunPython.noop),
]
+6 -4
View File
@@ -74,10 +74,10 @@ class CustomUser(AbstractUser):
default=True, help_text="How the conversations should display"
)
use_conversation_context = models.BooleanField(
default=True,
default=False,
help_text=(
"When enabled, earlier turns in the open chat are included "
"when answering a follow-up. Turn off to answer each message alone."
"When enabled, prior turns in the conversation are used as "
"LLM/RAG context for a more tailored experience"
),
)
@@ -525,7 +525,9 @@ class DriveConnection(TimeInfoBase):
help_text="Required for company connections; optional for personal (#55).",
)
provider = models.CharField(max_length=32, choices=Provider.choices)
kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.PERSONAL)
kind = models.CharField(
max_length=16, choices=Kind.choices, default=Kind.PERSONAL
)
access_token = models.TextField(blank=True, default="")
refresh_token = models.TextField(blank=True, default="")
token_expires_at = models.DateTimeField(null=True, blank=True)
+2 -2
View File
@@ -459,8 +459,8 @@ def resolve_user_from_profile(
upsert_identity(email_user, profile)
return email_user, False
# New account path. Password registration does not enable SSO sign-up.
allow_create = settings.ENABLE_ACCOUNT_REGISTRATION and settings.ENABLE_SSO
# New account path
allow_create = settings.ENABLE_ACCOUNT_REGISTRATION
if intent == "signup" and not allow_create:
raise OAuthError("registration_disabled", "Account registration is disabled.")
if intent == "login" and not allow_create:
+2 -9
View File
@@ -17,8 +17,6 @@ ROLE_EMBED = "embed"
# independent plan steps concurrently on a smaller/cheaper model.
ROLE_ORCHESTRATOR = "orchestrator"
ROLE_SUBAGENT = "subagent"
# Image + prompt turns (#128). Never falls back to the text chat model.
ROLE_VISION = "vision"
_VALID_ROLES = {
ROLE_THINKING,
@@ -27,7 +25,6 @@ _VALID_ROLES = {
ROLE_EMBED,
ROLE_ORCHESTRATOR,
ROLE_SUBAGENT,
ROLE_VISION,
}
@@ -59,7 +56,6 @@ def ollama_model_for_role(role: str) -> str:
ROLE_EMBED: "OLLAMA_EMBED_MODEL",
ROLE_ORCHESTRATOR: "OLLAMA_MODEL_ORCHESTRATOR",
ROLE_SUBAGENT: "OLLAMA_MODEL_SUBAGENT",
ROLE_VISION: "OLLAMA_MODEL_VISION",
}[role]
role_default = {
ROLE_THINKING: "gpt-oss:20b",
@@ -68,15 +64,14 @@ def ollama_model_for_role(role: str) -> str:
ROLE_EMBED: "nomic-embed-text",
ROLE_ORCHESTRATOR: "gpt-oss:20b",
ROLE_SUBAGENT: "llama3.2",
ROLE_VISION: "gemma4:latest",
}[role]
configured = getattr(settings, role_setting, None)
if configured:
return configured
# Embeddings and vision must never silently fall back to a text chat model.
if role in (ROLE_EMBED, ROLE_VISION):
# Embeddings must never silently fall back to a chat model (#62).
if role == ROLE_EMBED:
return role_default
legacy = getattr(settings, "OLLAMA_MODEL", None)
@@ -99,8 +94,6 @@ def ollama_num_ctx_for_role(role: str) -> int:
return int(getattr(settings, "OLLAMA_NUM_CTX_SUBAGENT", 8192) or 8192)
if role == ROLE_ORCHESTRATOR:
return int(getattr(settings, "OLLAMA_NUM_CTX_ORCHESTRATOR", 16384) or 16384)
if role == ROLE_VISION:
return int(getattr(settings, "OLLAMA_NUM_CTX_VISION", 8192) or 8192)
return int(getattr(settings, "OLLAMA_NUM_CTX_THINKING", 16384) or 16384)
+1 -9
View File
@@ -257,7 +257,6 @@ class PromptSerializer(serializers.ModelSerializer):
tokens_in = serializers.SerializerMethodField()
tokens_out = serializers.SerializerMethodField()
feedback = serializers.SerializerMethodField()
image = serializers.SerializerMethodField()
class Meta:
model = Prompt
@@ -270,10 +269,8 @@ class PromptSerializer(serializers.ModelSerializer):
"tokens_out",
"citations",
"feedback",
"file_type",
"image",
)
read_only_fields = ("citations", "feedback", "file_type", "image")
read_only_fields = ("citations", "feedback")
def _token_pair(self, obj):
cache = self.context.setdefault("_prompt_token_cache", {})
@@ -317,11 +314,6 @@ class PromptSerializer(serializers.ModelSerializer):
self.context["_prompt_feedback_by_id"] = by_prompt
return by_prompt.get(obj.id)
def get_image(self, obj):
from chat_backend.services.vision import image_base64
return image_base64(obj)
def validate_message(self, value: str) -> str:
if value is None or not str(value).strip():
raise serializers.ValidationError("Message text cannot be empty.")
@@ -13,18 +13,12 @@ ASSISTANT_SYSTEM_PROMPT = (
)
GROUNDED_ANSWER_INSTRUCTIONS = (
"You may have numbered live sources and the conversation so far. "
"Use the conversation for follow-ups: explain a previous answer, reformat it "
"(including a table of items already named), and reply to lines such as "
'"I don\'t know, why?". Do not say that list or answer is missing when it '
"is already in the conversation. "
"Cite source indexes inline like [1] or [2] only for facts taken from live sources. "
"If a new external fact (price, rating, date, news) is in neither the conversation "
"nor the sources, say that specific fact is unavailable, and still complete the "
"part the conversation already answers. "
"You have been given numbered live sources. Answer ONLY from those sources. "
"Cite source indexes inline like [1] or [2]. "
"If the sources do not settle the question, say so explicitly — do not fill "
"gaps from memory or training data. "
"When sources conflict, prefer the most recent dated source. "
"Never invent a date, number, name, or event that is in neither the conversation "
"nor the sources."
"Never state a date, number, name, or event that does not appear in the sources."
)
RETRIEVAL_FAILED_MESSAGE = (
@@ -88,8 +88,8 @@ async def prepare_grounded_chat(
``on_status(stage, detail)`` emits live activity frames (#96) when provided;
otherwise uses the context-bound emitter from :mod:`status_context`.
``use_conversation_context`` gates prior-turn history in the LLM prompt.
The account setting defaults on so follow-ups see this chat (#84).
``use_conversation_context`` gates prior-turn history in the LLM prompt
(#33); default off (opt-in).
"""
gen_kwargs = {"use_conversation_context": use_conversation_context}
internet = getattr(settings, "ALLOW_INTERNET_ACCESS", False)
@@ -104,9 +104,7 @@ async def prepare_grounded_chat(
)
await _emit(on_status, "evaluating")
# Prior turns exist once the current user message is not the only row.
has_history = bool(use_conversation_context) and len(messages) > 1
decision = await grounding_decider.decide_async(message, has_history=has_history)
decision = await grounding_decider.decide_async(message)
if not decision.needs_retrieval:
await _emit(on_status, "refining")
@@ -43,28 +43,6 @@ _TEMPORAL_PATTERNS = (
_TEMPORAL_RE = re.compile("|".join(_TEMPORAL_PATTERNS), re.IGNORECASE)
_YEAR_RE = re.compile(r"\b((?:19|20)\d{2})\b")
# Pure follow-ups should reuse the open chat, not a fresh web search (#84).
_PURE_FOLLOWUP_RE = re.compile(
r"("
r"\bi don'?t know\b"
r"|^\s*why\b"
r"|\bwhy\s*\??\s*$"
r"|\bwhat do you mean\b"
r"|\bexplain\b"
r"|\b(that|this|the|your) (list|joke|answer|setup|punchline)\b"
r"|\bmake (that|this|it|them)\b"
r"|\bput (that|this|it|them)\b"
r"|\bin a table\b"
r"|\binto a table\b"
r")",
re.IGNORECASE,
)
# These still need live sources even when the sentence points at earlier turns.
_NEW_EXTERNAL_FACT_RE = re.compile(
r"\b(price|prices|rating|ratings|cost|costs|review|reviews|cheapest)\b",
re.IGNORECASE,
)
@dataclass
class GroundingDecision:
@@ -129,28 +107,6 @@ message). Example: "did Taylor Swift get married" →
source="prepass",
)
def followup_prepass(
self, prompt: str, *, has_history: bool
) -> GroundingDecision | None:
"""Skip web search when the line only continues the open chat (#84)."""
if not has_history:
return None
text = (prompt or "").strip()
if not text or not _PURE_FOLLOWUP_RE.search(text):
return None
if _NEW_EXTERNAL_FACT_RE.search(text):
return None
temporal = self.temporal_prepass(text)
if temporal is not None and temporal.needs_retrieval:
return None
return GroundingDecision(
needs_retrieval=False,
reason="follow-up on the current conversation",
queries=[],
temporal=False,
source="prepass",
)
def _parse(self, raw: str, fallback_query: str) -> GroundingDecision:
text = (raw or "").strip()
# Strip markdown fences if the small model wraps JSON.
@@ -188,12 +144,7 @@ message). Example: "did Taylor Swift get married" →
source="model",
)
async def decide_async(
self, prompt: str, *, has_history: bool = False
) -> GroundingDecision:
follow = self.followup_prepass(prompt, has_history=has_history)
if follow is not None:
return follow
async def decide_async(self, prompt: str) -> GroundingDecision:
pre = self.temporal_prepass(prompt)
# Even on temporal prepass, ask the model for better queries when possible.
try:
@@ -222,10 +173,7 @@ message). Example: "did Taylor Swift get married" →
)
return decision
def decide(self, prompt: str, *, has_history: bool = False) -> GroundingDecision:
follow = self.followup_prepass(prompt, has_history=has_history)
if follow is not None:
return follow
def decide(self, prompt: str) -> GroundingDecision:
pre = self.temporal_prepass(prompt)
try:
raw = self.chain.invoke({"prompt": prompt})
+8 -32
View File
@@ -173,12 +173,16 @@ Response:"""
block (when grounded) is never trimmed.
"""
sources = self.sources_block or kwargs.get("sources_block", "") or ""
use_conversation_context = bool(kwargs.get("use_conversation_context", False))
use_conversation_context = bool(
kwargs.get("use_conversation_context", False)
)
history_text = ""
if use_conversation_context:
reserved = (
estimate_tokens(ASSISTANT_SYSTEM_PROMPT)
+ estimate_tokens(GROUNDED_ANSWER_INSTRUCTIONS if self.grounded else "")
+ estimate_tokens(
GROUNDED_ANSWER_INSTRUCTIONS if self.grounded else ""
)
+ estimate_tokens(sources)
+ estimate_tokens(query)
+ 256 # response headroom / instructions
@@ -199,9 +203,8 @@ Response:"""
and str(getattr(m, "content", "")).startswith("Live sources:")
)
]
# Current user turn is passed as {query}. Drop that text, not
# whichever row happens to be last.
prior = prior_turns(clean, query)
# Exclude the latest user turn from history (it's in {query}).
prior = clean[:-1] if clean else []
windowed = window_history(
prior, budget_tokens=history_budget, reserved_tokens=0
)
@@ -217,33 +220,6 @@ Response:"""
yield chunk
def message_text(message) -> str:
text = getattr(message, "content", "") or ""
if not isinstance(text, str):
text = str(text)
return text
def prior_turns(messages, query: str) -> list:
"""Drop the current user turn from history.
Match the query text, newest hit first, so an unordered transcript cannot
drop a different turn just because it sits at the end of the list (#84).
If the query is not in the list, keep every turn.
"""
if not messages:
return []
needle = (query or "").strip()
for index in range(len(messages) - 1, -1, -1):
message = messages[index]
if (
getattr(message, "type", "") == "human"
and message_text(message).strip() == needle
):
return list(messages[:index]) + list(messages[index + 1 :])
return list(messages)
def build_chat_service(
*,
model_name: str | None,
-186
View File
@@ -1,186 +0,0 @@
"""Image attachments on a chat turn (chat_web_app#128).
Document uploads stay on the csv/xlsx/pdf path. An image is stored on the
prompt and sent to a vision-capable Ollama model with the user's text.
"""
from __future__ import annotations
import base64
import json
import logging
import httpx
from chat_backend.ollama_config import (
ROLE_VISION,
ollama_base_url,
ollama_model_for_role,
ollama_num_ctx_for_role,
)
logger = logging.getLogger(__name__)
MAX_IMAGE_BYTES = 8 * 1024 * 1024
IMAGE_MIME = {
"png": "image/png",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"webp": "image/webp",
"gif": "image/gif",
}
_IMAGE_EXTENSIONS = frozenset(IMAGE_MIME)
class VisionChunk:
"""Stream piece that ``chunk_text`` and ``extract_token_usage`` both read."""
def __init__(self, text: str = "", info: dict | None = None):
self.content = text
self.generation_info = info or None
def canonical_file_type(file_type: str) -> str:
"""Map a browser MIME (or short extension) to the stored file_type.
Order matches the historical websocket normalizer so spreadsheets that
share the openxml MIME family still land on ``xlsx``.
"""
lowered = (file_type or "").lower()
if "csv" in lowered:
return "csv"
if "xmlformats-officedocument" in lowered:
return "xlsx"
if "word" in lowered:
return "docx"
if "pdf" in lowered:
return "pdf"
if "text" in lowered:
return "txt"
if "png" in lowered:
return "png"
if "jpeg" in lowered or "jpg" in lowered:
return "jpeg"
if "webp" in lowered:
return "webp"
if "gif" in lowered:
return "gif"
if lowered.startswith("image/"):
return "unsupported_image"
return "Not Sure"
def is_image_type(file_type: str | None) -> bool:
return (file_type or "").lower() in _IMAGE_EXTENSIONS
def image_mime(file_type: str | None) -> str:
return IMAGE_MIME.get((file_type or "").lower(), "application/octet-stream")
def prepare_attachment(file_b64: str, file_type: str) -> tuple[bytes, str, str | None]:
"""Decode an upload. ``error`` is a user-facing string when the image is rejected."""
try:
decoded = base64.b64decode(file_b64, validate=False)
except Exception:
return b"", "Not Sure", "Could not read the attached file."
canonical = canonical_file_type(file_type)
if canonical == "unsupported_image":
return (
decoded,
canonical,
"Unsupported image. Use PNG, JPEG, WebP, or GIF.",
)
if is_image_type(canonical) and len(decoded) > MAX_IMAGE_BYTES:
return (
decoded,
canonical,
"Image is too large. Maximum size is 8 MB.",
)
return decoded, canonical, None
def image_base64(prompt) -> str | None:
"""Base64 of a stored prompt image, or None for non-images."""
if not is_image_type(getattr(prompt, "file_type", None)):
return None
if not prompt.file_exists():
return None
try:
data = prompt.file.read()
except Exception:
logger.exception(
"failed to read prompt image prompt_id=%s", getattr(prompt, "id", None)
)
return None
if not data:
return None
if isinstance(data, str):
data = data.encode()
return base64.b64encode(data).decode("ascii")
class AsyncVisionService:
"""Stream an answer from Ollama's chat API with one image."""
def __init__(self):
self.model_name = ollama_model_for_role(ROLE_VISION)
self.num_ctx = ollama_num_ctx_for_role(ROLE_VISION)
async def generate_response(self, query: str, image_bytes: bytes, file_type: str):
payload = {
"model": self.model_name,
"stream": True,
"options": {"num_ctx": self.num_ctx},
"messages": [
{
"role": "user",
"content": (
"The user attached an image. Answer their question using what "
"is visible in the image, including any text in it.\n\n"
f"{query}"
),
"images": [base64.b64encode(image_bytes).decode("ascii")],
}
],
}
url = f"{ollama_base_url().rstrip('/')}/api/chat"
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(180.0)) as client:
async with client.stream("POST", url, json=payload) as response:
if response.status_code >= 400:
body = await response.aread()
logger.error(
"vision model rejected request status=%s body=%s",
response.status_code,
body[:500],
)
yield VisionChunk(
text="The vision model could not read that image."
)
return
async for line in response.aiter_lines():
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
message = event.get("message") or {}
text = message.get("content") or ""
if text:
yield VisionChunk(text=text)
if event.get("done"):
info = {
key: event[key]
for key in ("prompt_eval_count", "eval_count")
if key in event
}
if info:
yield VisionChunk(info=info)
except httpx.HTTPError:
logger.exception("vision request failed model=%s", self.model_name)
yield VisionChunk(text="The vision model could not read that image.")
@@ -23,7 +23,6 @@ STATUS_LABELS: dict[str, str] = {
"reading_sources": "Reading sources",
"retrieving_docs": "Searching your documents",
"analysing": "Analysing your file",
"reading_image": "Reading your image",
"refining": "Refining the answer",
"writing": "", # tokens streaming — no label
}
+10 -75
View File
@@ -1,11 +1,9 @@
import json
from datetime import timedelta
from unittest import mock
import json
from asgiref.sync import sync_to_async
from channels.testing import WebsocketCommunicator
from django.test import TestCase, TransactionTestCase, override_settings
from django.utils import timezone
from langchain_core.messages import AIMessage, HumanMessage
from parameterized import parameterized
@@ -49,7 +47,9 @@ class DatabaseHelperTestCase(TransactionTestCase):
@parameterized.expand([("websocket", consumers), ("langgraph", consumers_graph)])
async def test_get_workspace(self, _name, module):
workspace = await module.get_workspace(self.conversation.id, user=self.user)
workspace = await module.get_workspace(
self.conversation.id, user=self.user
)
self.assertEqual(workspace.id, self.workspace.id)
@@ -99,29 +99,6 @@ class DatabaseHelperTestCase(TransactionTestCase):
[type(message) for message in messages], [AIMessage, HumanMessage]
)
@parameterized.expand([("websocket", consumers), ("langgraph", consumers_graph)])
async def test_get_messages_orders_by_created_not_insert_order(self, _name, module):
now = timezone.now()
await sync_to_async(Prompt.objects.create)(
conversation=self.conversation,
message="newer answer",
user_created=False,
created=now,
)
await sync_to_async(Prompt.objects.create)(
conversation=self.conversation,
message="older question",
user_created=True,
created=now - timedelta(minutes=5),
)
messages, _prompt = await module.get_messages(self.conversation.id, "follow up")
self.assertEqual(
[message.content for message in messages],
["older question", "newer answer", "follow up"],
)
async def test_get_messages_stores_attachment_and_inlines_csv(self):
messages, prompt_instance = await consumers.get_messages(
self.conversation.id, "analyze this", b"name,sales\nalice,10\n", "csv"
@@ -378,42 +355,6 @@ class GraphNodeTestCase(TransactionTestCase):
self.prompt.message, b"a,b\n1,2\n", "csv"
)
async def test_generation_node_sends_images_to_vision(self):
with mock.patch.object(consumers_graph, "AsyncVisionService") as service:
service.return_value.model_name = "gemma4:latest"
service.return_value.generate_response.return_value = "generator"
result = await consumers_graph.generation_node(
self._state(
prompt_type=PromptType.DATA_ANALYSIS,
message="what does this say?",
decoded_file=b"\x89PNG",
file_type="png",
)
)
self.assertEqual(result["response_generator"], "generator")
self.assertEqual(result["resolved_model"], "gemma4:latest")
service.return_value.generate_response.assert_called_once_with(
self.prompt.message, b"\x89PNG", "png"
)
async def test_attached_image_is_not_data_analysis(self):
with mock.patch.object(
consumers_graph.PROMPT_CLASSIFIER,
"classify_async",
mock.AsyncMock(return_value=PromptType.DATA_ANALYSIS),
):
result = await consumers_graph.classification_node(
self._state(
message="analyze this chart",
decoded_file=b"\x89PNG",
file_type="png",
)
)
self.assertEqual(result["prompt_type"], PromptType.GENERAL_CHAT)
async def test_generation_node_routes_rag_prompts_with_workspace(self):
with mock.patch.object(consumers_graph, "AsyncRAGService") as service:
service.return_value.generate_response.return_value = "generator"
@@ -610,16 +551,13 @@ class WebSocketReceiveGuardTestCase(TransactionTestCase):
connected, _ = await communicator.connect()
self.assertTrue(connected)
with (
mock.patch(
with mock.patch(
"chat_backend.consumers.title_generator.generate_async",
new_callable=mock.AsyncMock,
) as title_chat,
mock.patch(
) as title_chat, mock.patch(
"chat_backend.consumers_graph.title_generator.generate_async",
new_callable=mock.AsyncMock,
) as title_graph,
):
) as title_graph:
await communicator.send_json_to({"type": "ping", "email": "a@b.com"})
# No reply expected; give the event loop a tick.
self.assertTrue(await communicator.receive_nothing(timeout=0.2))
@@ -638,16 +576,13 @@ class WebSocketReceiveGuardTestCase(TransactionTestCase):
connected, _ = await communicator.connect()
self.assertTrue(connected)
with (
mock.patch(
with mock.patch(
"chat_backend.consumers.title_generator.generate_async",
new_callable=mock.AsyncMock,
) as title_chat,
mock.patch(
) as title_chat, mock.patch(
"chat_backend.consumers_graph.title_generator.generate_async",
new_callable=mock.AsyncMock,
) as title_graph,
):
) as title_graph:
await communicator.send_json_to(
{"message": " ", "email": "a@b.com", "conversation_id": None}
)
@@ -86,25 +86,6 @@ class GroundingPrepassTestCase(SimpleTestCase):
self.assertEqual(decision.source, "fail_open")
self.assertEqual(decision.queries, ["fallback query"])
def test_pure_followup_skips_retrieval_without_calling_model(self):
self.decider.chain = mock.Mock()
decision = self.decider.decide("I don't know. Why?", has_history=True)
self.assertFalse(decision.needs_retrieval)
self.assertEqual(decision.reason, "follow-up on the current conversation")
self.decider.chain.invoke.assert_not_called()
def test_followup_without_history_does_not_short_circuit(self):
self.assertIsNone(
self.decider.followup_prepass("I don't know. Why?", has_history=False)
)
def test_price_followup_still_allows_retrieval(self):
decision = self.decider.followup_prepass(
"Can you make that list for me and add the prices and ratings in a table",
has_history=True,
)
self.assertIsNone(decision)
def test_decide_fail_open_on_llm_error(self):
self.decider.chain = mock.Mock()
self.decider.chain.invoke.side_effect = RuntimeError("ollama down")
@@ -1,16 +0,0 @@
from django.db import connection
from django.db.migrations.loader import MigrationLoader
from django.test import TestCase
class MigrationGraphTests(TestCase):
def test_chat_backend_has_a_single_leaf(self):
loader = MigrationLoader(connection, ignore_no_migrations=True)
leaves = [
name for app, name in loader.graph.leaf_nodes() if app == "chat_backend"
]
self.assertEqual(leaves, ["0036_enable_conversation_context"])
node = loader.graph.nodes[("chat_backend", "0036_enable_conversation_context")]
parents = [name for app, name in node.dependencies if app == "chat_backend"]
self.assertEqual(parents, ["0035_waitlistentry"])
+1 -1
View File
@@ -81,7 +81,7 @@ class CompanyAndUserTestCase(TestCase):
self.assertFalse(user.deleted)
self.assertFalse(user.has_signed_tos)
self.assertTrue(user.conversation_order)
self.assertTrue(user.use_conversation_context)
self.assertFalse(user.use_conversation_context)
class ConversationAndPromptTestCase(TestCase):
-38
View File
@@ -24,7 +24,6 @@ OAUTH_SETTINGS = {
"FRONTEND_BASE_URL": "http://frontend.test",
"OAUTH_CALLBACK_BASE_URL": "http://backend.test",
"ENABLE_ACCOUNT_REGISTRATION": True,
"ENABLE_SSO": True,
}
@@ -54,14 +53,6 @@ class PublicSettingsOAuthTestCase(APITestCase):
self.assertTrue(response.data["oauth"]["google"])
self.assertTrue(response.data["oauth"]["microsoft"])
@override_settings(ENABLE_SSO=False)
def test_hides_oauth_when_sso_disabled(self):
response = self.client.get(reverse("public_settings"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data["enable_account_registration"])
self.assertFalse(response.data["oauth"]["google"])
self.assertFalse(response.data["oauth"]["microsoft"])
@override_settings(GOOGLE_OAUTH_CLIENT_ID="", GOOGLE_OAUTH_CLIENT_SECRET="")
def test_hides_unconfigured_provider(self):
response = self.client.get(reverse("public_settings"))
@@ -92,15 +83,6 @@ class OAuthStartTestCase(APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@override_settings(ENABLE_SSO=False)
def test_login_and_signup_blocked_when_sso_disabled(self):
for intent in ("login", "signup"):
response = self.client.get(
reverse("oauth_start", kwargs={"provider": "google"}),
{"intent": intent},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
def test_signup_intent_blocked_when_registration_disabled(self):
response = self.client.get(
@@ -198,17 +180,6 @@ class OAuthCallbackTestCase(APITestCase):
params = parse_qs(urlparse(response["Location"]).query)
self.assertEqual(params["error"], ["email_missing"])
@patch("chat_backend.views_oauth.exchange_code_for_profile")
@override_settings(ENABLE_SSO=False)
def test_callback_signup_rejected_when_sso_disabled(self, mock_exchange):
mock_exchange.return_value = _google_profile()
response = self._callback(intent="signup")
params = parse_qs(urlparse(response["Location"]).query)
self.assertEqual(params["error"], ["sso_disabled"])
self.assertEqual(CustomUser.objects.count(), 0)
mock_exchange.assert_not_called()
@patch("chat_backend.views_oauth.exchange_code_for_profile")
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
def test_callback_login_without_account_when_registration_off(self, mock_exchange):
@@ -285,15 +256,6 @@ class OAuthStartDriveLinkTestCase(APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(ENABLE_SSO=False)
def test_link_drive_still_starts_when_sso_disabled(self):
self.client.force_authenticate(user=self.user)
response = self.client.get(
reverse("oauth_start", kwargs={"provider": "google"}),
{"intent": "link_drive"},
)
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
def test_link_drive_authenticated_redirects_with_drive_scope_and_state(self):
self.client.force_authenticate(user=self.user)
response = self.client.get(
+8 -54
View File
@@ -1,12 +1,7 @@
from django.test import SimpleTestCase
from langchain_core.messages import AIMessage, HumanMessage
from chat_backend.services.assistant_identity import GROUNDED_ANSWER_INSTRUCTIONS
from chat_backend.services.llm_service import (
AsyncLLMService,
SyncLLMService,
prior_turns,
)
from chat_backend.services.llm_service import AsyncLLMService, SyncLLMService
from chat_backend.services.prompt_budget import format_history, window_history
from .fakes import FakeChain
@@ -41,7 +36,7 @@ class AsyncLLMServiceTestCase(SimpleTestCase):
async def test_generate_response_sends_single_history_window(self):
self.service.conversation_chain = FakeChain(chunks=["ok"])
messages = conversation(4) + [HumanMessage(content="latest")]
messages = conversation(4) # 8 messages
async for _ in self.service.generate_response(
messages, "latest", 1, use_conversation_context=True
@@ -52,33 +47,10 @@ class AsyncLLMServiceTestCase(SimpleTestCase):
self.assertEqual(payload["query"], "latest")
# Latest user turn is in {query}; history is prior turns only, once.
self.assertIn("history", payload)
self.assertNotIn("User: latest", payload["history"])
self.assertIn("AI: answer 3", payload["history"])
self.assertNotIn("recent_conversation", payload)
self.assertNotIn("conversation", payload)
self.assertLessEqual(len(payload["history"].splitlines()), 8)
async def test_history_drops_matching_turn_even_if_it_is_not_last(self):
self.service.conversation_chain = FakeChain(chunks=["ok"])
# Unordered transcript: current question is first, older turns follow.
messages = [
HumanMessage(content="I don't know. Why?"),
AIMessage(content="Why did the meditation instructor bring a ladder?"),
HumanMessage(content="Tell me a joke"),
]
async for _ in self.service.generate_response(
messages,
"I don't know. Why?",
1,
use_conversation_context=True,
):
pass
history = self.service.conversation_chain.calls[0]["history"]
self.assertNotIn("I don't know", history)
self.assertIn("Tell me a joke", history)
self.assertIn("meditation instructor", history)
# 8 messages → drop last (query) → 7 prior lines max in window.
self.assertLessEqual(len(payload["history"].splitlines()), 7)
async def test_generate_response_skips_history_when_context_disabled(self):
self.service.conversation_chain = FakeChain(chunks=["ok"])
@@ -101,9 +73,7 @@ class AsyncLLMServiceTestCase(SimpleTestCase):
self.assertEqual(self.service.conversation_chain.calls[0]["history"], "")
async def test_grounded_service_includes_sources(self):
service = AsyncLLMService(
grounded=True, sources_block='[1] "T" — x.com — undated'
)
service = AsyncLLMService(grounded=True, sources_block='[1] "T" — x.com — undated')
service.conversation_chain = FakeChain(chunks=["ok"])
async for _ in service.generate_response(
@@ -120,24 +90,6 @@ class SyncLLMServiceTestCase(SimpleTestCase):
self.assertIsNotNone(SyncLLMService())
class PriorTurnsTestCase(SimpleTestCase):
def test_grounded_instructions_keep_the_conversation(self):
self.assertIn("conversation", GROUNDED_ANSWER_INSTRUCTIONS.lower())
self.assertNotIn("Answer ONLY from those sources", GROUNDED_ANSWER_INSTRUCTIONS)
def test_prior_turns_drops_newest_matching_user_text(self):
messages = [
HumanMessage(content="I don't know. Why?"),
AIMessage(content="the joke"),
HumanMessage(content="Tell me a joke"),
]
kept = prior_turns(messages, "I don't know. Why?")
self.assertEqual(
[message.content for message in kept],
["the joke", "Tell me a joke"],
)
class PromptBudgetTestCase(SimpleTestCase):
def test_window_history_drops_oldest_first(self):
messages = [HumanMessage(content="x" * 40) for _ in range(10)]
@@ -147,5 +99,7 @@ class PromptBudgetTestCase(SimpleTestCase):
self.assertEqual(kept[-1].content, messages[-1].content)
def test_format_history_labels_speakers(self):
text = format_history([HumanMessage(content="hello"), AIMessage(content="hi")])
text = format_history(
[HumanMessage(content="hello"), AIMessage(content="hi")]
)
self.assertEqual(text, "User: hello\nAI: hi")
@@ -1,7 +1,4 @@
from datetime import timedelta
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
@@ -60,7 +57,7 @@ class ConversationPreferencesTestCase(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data["order"])
self.assertTrue(response.data["use_conversation_context"])
self.assertFalse(response.data["use_conversation_context"])
def test_post_toggles_and_persists_order(self):
response = self.client.post(self.url)
@@ -118,33 +115,8 @@ class ConversationDetailViewTestCase(APITestCase):
[("hello", True), ("hi there", False)],
)
def test_get_returns_prompts_oldest_first_even_if_inserted_newest_first(self):
now = timezone.now()
make_prompt(
self.conversation,
message="later",
user_created=False,
created=now,
)
make_prompt(
self.conversation,
message="earlier",
user_created=True,
created=now - timedelta(minutes=5),
)
response = self.client.get(self.url, {"conversation_id": self.conversation.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[item["message"] for item in response.data],
["earlier", "later"],
)
def test_get_rejects_other_users_conversation(self):
other = make_user(
email="other-tenant@example.com", company=make_company("Other")
)
other = make_user(email="other-tenant@example.com", company=make_company("Other"))
foreign = make_conversation(user=other)
make_prompt(foreign, message="secret")
+1 -19
View File
@@ -115,27 +115,9 @@ class PublicSettingsTestCase(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["enable_account_registration"], False)
def test_registration_on_and_sso_hidden_by_default(self):
response = self.client.get(reverse("public_settings"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["enable_account_registration"], True)
self.assertEqual(response.data["oauth"], {"google": False, "microsoft": False})
class UserCreateTestCase(APITestCase):
def test_registration_enabled_by_default(self):
response = self.client.post(
reverse("create_user"),
{"email": "new@example.com", "password": "securepass1"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(CustomUser.objects.filter(email="new@example.com").exists())
def test_registration_disabled_when_setting_off(self):
with self.settings(ENABLE_ACCOUNT_REGISTRATION=False):
def test_registration_disabled_by_default(self):
response = self.client.post(
reverse("create_user"),
{"email": "new@example.com", "password": "securepass1"},
-64
View File
@@ -1,64 +0,0 @@
import base64
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from chat_backend.services.vision import (
canonical_file_type,
image_base64,
is_image_type,
prepare_attachment,
)
from chat_backend.tests.factories import make_conversation, make_prompt
class VisionAttachmentTestCase(TestCase):
def test_canonical_types(self):
self.assertEqual(canonical_file_type("image/png"), "png")
self.assertEqual(canonical_file_type("image/jpeg"), "jpeg")
self.assertEqual(canonical_file_type("image/jpg"), "jpeg")
self.assertEqual(canonical_file_type("image/webp"), "webp")
self.assertEqual(canonical_file_type("image/gif"), "gif")
self.assertEqual(canonical_file_type("text/plain"), "txt")
self.assertEqual(canonical_file_type("application/pdf"), "pdf")
self.assertEqual(canonical_file_type("image/bmp"), "unsupported_image")
self.assertEqual(canonical_file_type("image/svg+xml"), "unsupported_image")
def test_prepare_attachment_rejects_oversize_and_unknown_images(self):
png = base64.b64encode(b"\x89PNG").decode()
decoded, kind, error = prepare_attachment(png, "image/png")
self.assertEqual(decoded, b"\x89PNG")
self.assertEqual(kind, "png")
self.assertIsNone(error)
self.assertTrue(is_image_type(kind))
huge = base64.b64encode(b"x" * (8 * 1024 * 1024 + 1)).decode()
_, _, error = prepare_attachment(huge, "image/png")
self.assertIn("too large", error)
_, _, error = prepare_attachment(png, "image/bmp")
self.assertIn("Unsupported image", error)
def test_document_types_unchanged(self):
raw = base64.b64encode(b"a,b\n1,2\n").decode()
decoded, kind, error = prepare_attachment(raw, "text/csv")
self.assertEqual(kind, "csv")
self.assertIsNone(error)
self.assertEqual(decoded, b"a,b\n1,2\n")
self.assertFalse(is_image_type(kind))
def test_image_base64_reads_stored_png(self):
prompt = make_prompt(make_conversation())
prompt.file_type = "png"
prompt.file.save("shot.png", SimpleUploadedFile("shot.png", b"\x89PNG"))
prompt.save()
self.assertEqual(image_base64(prompt), base64.b64encode(b"\x89PNG").decode())
def test_image_base64_skips_documents(self):
prompt = make_prompt(make_conversation())
prompt.file_type = "csv"
prompt.file.save("data.csv", SimpleUploadedFile("data.csv", b"a,b"))
prompt.save()
self.assertIsNone(image_base64(prompt))
+3 -3
View File
@@ -178,7 +178,7 @@ class PublicWaitlistView(APIView):
class CustomUserCreate(APIView):
"""Password self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION."""
"""Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off)."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
@@ -680,7 +680,7 @@ class ConversationDetailView(APIView):
prompts = list(
Prompt.objects.filter(
conversation__id=conversation_id, conversation__user=request.user
).order_by("created", "id")
)
)
serializer = PromptSerializer(
prompts,
@@ -734,7 +734,7 @@ class ConversationDetailView(APIView):
messages = []
for prompt_obj in Prompt.objects.filter(
conversation__id=conversation_id
).order_by("created", "id"):
):
messages.append(
{
"content": prompt_obj.message,
+1 -10
View File
@@ -102,11 +102,6 @@ class OAuthStartView(APIView):
},
status=status.HTTP_400_BAD_REQUEST,
)
if intent in {"login", "signup"} and not settings.ENABLE_SSO:
return Response(
{"detail": "Single sign-on is not enabled."},
status=status.HTTP_403_FORBIDDEN,
)
if intent == "signup" and not settings.ENABLE_ACCOUNT_REGISTRATION:
return Response(
{"detail": "Account registration is disabled."},
@@ -189,8 +184,6 @@ class OAuthCallbackView(APIView):
return self._handle_drive_link_callback(
request, provider=provider, code=code, state_data=state_data
)
if not settings.ENABLE_SSO:
return _redirect_error("sso_disabled", "Single sign-on is not enabled.")
try:
redirect_uri = _callback_redirect_uri(request, provider)
@@ -268,7 +261,5 @@ class OAuthCallbackView(APIView):
def oauth_public_flags() -> dict:
"""Feature flags for /public/settings/. SSO buttons stay hidden until ENABLE_SSO."""
if not settings.ENABLE_SSO:
return {"google": False, "microsoft": False}
"""Feature flags for /public/settings/."""
return configured_providers()
+4 -23
View File
@@ -169,10 +169,6 @@ OLLAMA_NUM_CTX_UTILITY = int(env("OLLAMA_NUM_CTX_UTILITY", "4096") or "4096")
# sub-agents run independent plan steps on a smaller/cheaper model.
OLLAMA_MODEL_ORCHESTRATOR = env("OLLAMA_MODEL_ORCHESTRATOR", "") or OLLAMA_MODEL
OLLAMA_MODEL_SUBAGENT = env("OLLAMA_MODEL_SUBAGENT", "") or OLLAMA_MODEL_UTILITY
# Vision turns (image + prompt). Must be a vision-capable Ollama tag.
# Empty falls back to gemma4:latest inside ollama_model_for_role, not OLLAMA_MODEL.
OLLAMA_MODEL_VISION = env("OLLAMA_MODEL_VISION", "") or ""
OLLAMA_NUM_CTX_VISION = int(env("OLLAMA_NUM_CTX_VISION", "8192") or "8192")
OLLAMA_NUM_CTX_ORCHESTRATOR = int(
env("OLLAMA_NUM_CTX_ORCHESTRATOR", "16384") or "16384"
)
@@ -385,12 +381,9 @@ AGENT_FETCH_URL_MAX_BYTES = int(
env("AGENT_FETCH_URL_MAX_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024)
)
# Password self-serve sign-up. On by default (#80). Set false in the
# control-node secret (chat_backend_<env>.env) to close public registration.
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", True)
# Browser SSO (Google / Microsoft sign-in and sign-up). Off until explicitly
# enabled. OAuth client ids may still be set for Drive linking (#47).
ENABLE_SSO = env_bool("ENABLE_SSO", False)
# Self-serve account registration (sign-up page). Default off — enable via
# control-node secret (chat_backend_<env>.env) when ready for public sign-up.
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", False)
# ---------------------------------------------------------------------------
# OAuth SSO (Google / Microsoft) — #24
@@ -415,20 +408,8 @@ STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "") or ""
# price_data built from SUBSCRIPTION_PRICE_* below.
STRIPE_PRICE_ID = env("STRIPE_PRICE_ID", "") or ""
# Legacy RevenueCat webhook secret. Store grants no longer use that webhook.
# RevenueCat webhook Authorization bearer secret + optional product→plan map.
REVENUECAT_WEBHOOK_SECRET = env("REVENUECAT_WEBHOOK_SECRET", "") or ""
# Native store verification. Apple signed transactions chain to the bundled
# Apple Root CA G3 unless APPLE_IAP_ROOT_PEM adds another PEM.
APPLE_IAP_BUNDLE_ID = (
env("APPLE_IAP_BUNDLE_ID", "ai.hesychia.chat") or "ai.hesychia.chat"
)
APPLE_IAP_ROOT_PEM = env("APPLE_IAP_ROOT_PEM", "") or ""
GOOGLE_PLAY_PACKAGE_NAME = (
env("GOOGLE_PLAY_PACKAGE_NAME", "ai.hesychia.chat") or "ai.hesychia.chat"
)
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON = (
env("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or ""
)
_rc_product_map_raw = env("REVENUECAT_PRODUCT_PLAN_MAP", "") or ""
REVENUECAT_PRODUCT_PLAN_MAP: dict = {}
if _rc_product_map_raw.strip():
@@ -1,15 +0,0 @@
-----BEGIN CERTIFICATE-----
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
6BgD56KyKA==
-----END CERTIFICATE-----
@@ -1,56 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0005_revenuecat_fields"),
]
operations = [
migrations.AlterField(
model_name="invoice",
name="provider",
field=models.CharField(
choices=[
("stripe", "Stripe"),
("revenuecat", "RevenueCat"),
("app_store", "App Store"),
("play_store", "Play Store"),
],
default="stripe",
max_length=32,
),
),
migrations.AlterField(
model_name="payment",
name="provider",
field=models.CharField(
choices=[
("stripe", "Stripe"),
("revenuecat", "RevenueCat"),
("app_store", "App Store"),
("play_store", "Play Store"),
],
default="stripe",
max_length=32,
),
),
migrations.AlterField(
model_name="usersubscription",
name="source",
field=models.CharField(
choices=[
("none", "None"),
("stripe", "Stripe"),
("revenuecat", "RevenueCat"),
("app_store", "App Store"),
("play_store", "Play Store"),
("backer", "Backer"),
("admin", "Admin"),
],
default="none",
max_length=32,
),
),
]
-6
View File
@@ -134,8 +134,6 @@ class UserSubscription(TimeInfoBase):
NONE = "none", "None"
STRIPE = "stripe", "Stripe"
REVENUECAT = "revenuecat", "RevenueCat"
APP_STORE = "app_store", "App Store"
PLAY_STORE = "play_store", "Play Store"
BACKER = "backer", "Backer"
ADMIN = "admin", "Admin"
@@ -216,8 +214,6 @@ class Invoice(TimeInfoBase):
class Provider(models.TextChoices):
STRIPE = "stripe", "Stripe"
REVENUECAT = "revenuecat", "RevenueCat"
APP_STORE = "app_store", "App Store"
PLAY_STORE = "play_store", "Play Store"
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
@@ -312,8 +308,6 @@ class Payment(TimeInfoBase):
class Provider(models.TextChoices):
STRIPE = "stripe", "Stripe"
REVENUECAT = "revenuecat", "RevenueCat"
APP_STORE = "app_store", "App Store"
PLAY_STORE = "play_store", "Play Store"
class Status(models.TextChoices):
PENDING = "pending", "Pending"
-16
View File
@@ -59,23 +59,8 @@ class PortalSessionSerializer(serializers.Serializer):
return_url = serializers.URLField(required=False, allow_blank=False)
class StorePurchaseConfirmSerializer(serializers.Serializer):
store = serializers.ChoiceField(choices=("app_store", "play_store"))
product_id = serializers.CharField(max_length=255)
transaction_id = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
purchase_token = serializers.CharField(
max_length=4096, required=False, allow_blank=True
)
signed_transaction = serializers.CharField(required=False, allow_blank=True)
class SubscriptionPlanSerializer(serializers.ModelSerializer):
features = serializers.SerializerMethodField()
store_product_id = serializers.CharField(
source="revenuecat_product_id", read_only=True
)
class Meta:
model = SubscriptionPlan
@@ -93,7 +78,6 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
"prompt_window_hours",
"monthly_token_quota",
"sort_order",
"store_product_id",
]
read_only_fields = fields
+2 -5
View File
@@ -247,8 +247,6 @@ def needs_checkout(user) -> bool:
UserSubscription.Source.ADMIN,
UserSubscription.Source.STRIPE,
UserSubscription.Source.REVENUECAT,
UserSubscription.Source.APP_STORE,
UserSubscription.Source.PLAY_STORE,
):
return False
return True
@@ -459,9 +457,8 @@ def assign_plan_from_revenuecat(
cancel_at_period_end: bool | None = None,
current_period_end=None,
keep_existing_plan_if_unknown: bool = False,
source: str | None = None,
) -> UserSubscription:
"""Assign a catalog plan from a verified store purchase."""
"""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()
@@ -496,7 +493,7 @@ def assign_plan_from_revenuecat(
sub = assign_plan(
user,
plan=plan,
source=source or UserSubscription.Source.REVENUECAT,
source=UserSubscription.Source.REVENUECAT,
status=status,
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
log_auth_event=False,
@@ -1,617 +0,0 @@
"""Verify App Store and Play Store purchases and grant entitlements.
The mobile app completes the purchase with StoreKit or Play Billing, then
POSTs the signed transaction or purchase token. This module checks that
payload with Apple or Google and writes the subscription ledger. Store
webhooks are not accepted.
"""
from __future__ import annotations
import base64
import json
import logging
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone as dt_timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote
import jwt
import requests
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, padding
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from monetization.models import Invoice, Payment, UserSubscription
from monetization.services.plans import (
assign_plan_from_revenuecat,
log_subscription_auth_event,
resolve_plan_from_revenuecat_product,
)
logger = logging.getLogger(__name__)
# uuid5(NAMESPACE_URL, this name). Do not change; clients receive the result.
_STORE_ACCOUNT_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://chat.aimloperations.com/store-account",
)
_APP_STORE_CERT_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
_PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
_BUNDLED_APPLE_ROOT = (
Path(__file__).resolve().parent.parent / "certs" / "apple_root_g3.pem"
)
_STORE_SOURCES = frozenset(
{
UserSubscription.Source.APP_STORE,
UserSubscription.Source.PLAY_STORE,
UserSubscription.Source.REVENUECAT,
}
)
class StoreNotConfiguredError(Exception):
"""Apple or Google credentials required to verify a purchase are missing."""
class StorePurchaseRejected(Exception):
"""The store payload is not an active purchase for this user."""
class StoreVerificationError(Exception):
"""The store API could not be reached."""
@dataclass(frozen=True)
class VerifiedStorePurchase:
source: str
store_label: str
product_id: str
transaction_id: str
original_transaction_id: str
expires_at: datetime | None
purchased_at: datetime | None
cancel_at_period_end: bool
status: str
price_cents: int
currency: str
account_token: str
def store_account_token(user_id: int) -> str:
"""Stable store account id for this user. Pass it to StoreKit and Play."""
return str(uuid.uuid5(_STORE_ACCOUNT_NAMESPACE, str(user_id)))
def expire_lapsed_store_subscription(sub: UserSubscription | None):
"""End store access once the verified period has passed.
Renewals arrive when the app confirms the store entitlement again.
"""
if sub is None or sub.source not in _STORE_SOURCES:
return sub
if sub.status != UserSubscription.Status.ACTIVE or not sub.current_period_end:
return sub
if sub.current_period_end > timezone.now():
return sub
prev_status = sub.status
sub.status = UserSubscription.Status.CANCELED
sub.cancel_at_period_end = False
sub.save(update_fields=["status", "cancel_at_period_end", "last_modified"])
if prev_status != UserSubscription.Status.CANCELED:
log_subscription_auth_event(
sub.user,
started=False,
detail=(
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
f"source={sub.source} status={sub.status} "
"reason=store_period_ended"
),
)
return sub
def confirm_store_purchase(user, payload: dict[str, Any]) -> UserSubscription:
"""Verify one store purchase for the authenticated user and grant it."""
store = (payload.get("store") or "").strip().lower()
product_id = (payload.get("product_id") or "").strip()
if store == UserSubscription.Source.APP_STORE:
verified = verify_apple_signed_transaction(
signed_transaction=(payload.get("signed_transaction") or "").strip(),
product_id=product_id,
transaction_id=(payload.get("transaction_id") or "").strip(),
)
elif store == UserSubscription.Source.PLAY_STORE:
verified = verify_google_subscription(
product_id=product_id,
purchase_token=(payload.get("purchase_token") or "").strip(),
transaction_id=(payload.get("transaction_id") or "").strip(),
)
else:
raise StorePurchaseRejected("store must be app_store or play_store.")
if verified.product_id != product_id:
raise StorePurchaseRejected("Store product does not match this purchase.")
return grant_verified_store_purchase(user, verified)
def verify_apple_signed_transaction(
*,
signed_transaction: str,
product_id: str,
transaction_id: str = "",
) -> VerifiedStorePurchase:
"""Verify a StoreKit 2 signed transaction against Apple's root."""
if not signed_transaction:
raise StorePurchaseRejected("App Store purchases require signed_transaction.")
bundle_id = (getattr(settings, "APPLE_IAP_BUNDLE_ID", "") or "").strip()
if not bundle_id:
raise StoreNotConfiguredError("APPLE_IAP_BUNDLE_ID is not configured.")
roots = _load_apple_roots()
if not roots:
raise StoreNotConfiguredError("No Apple root certificate is configured.")
try:
header = jwt.get_unverified_header(signed_transaction)
except jwt.PyJWTError as exc:
raise StorePurchaseRejected("Invalid App Store signed transaction.") from exc
x5c = header.get("x5c") if isinstance(header, dict) else None
if not isinstance(x5c, list) or not x5c:
raise StorePurchaseRejected("App Store transaction is missing its certificate.")
try:
certs = [_load_der_cert(item) for item in x5c]
leaf = _trusted_leaf(certs, roots)
except StorePurchaseRejected:
raise
except Exception as exc:
raise StorePurchaseRejected("App Store certificate chain is not trusted.") from exc
_require_app_store_oid(leaf)
try:
claims = jwt.decode(
signed_transaction,
leaf.public_key(),
algorithms=["ES256"],
options={"verify_aud": False, "verify_exp": False},
)
except jwt.PyJWTError as exc:
raise StorePurchaseRejected("App Store transaction signature is invalid.") from exc
if not isinstance(claims, dict):
raise StorePurchaseRejected("App Store transaction payload is invalid.")
claim_bundle = str(claims.get("bundleId") or "")
if claim_bundle != bundle_id:
raise StorePurchaseRejected("App Store bundle id does not match this app.")
claim_product = str(claims.get("productId") or "")
if product_id and claim_product != product_id:
raise StorePurchaseRejected("App Store product does not match this purchase.")
claim_txn = str(claims.get("transactionId") or "")
if transaction_id and claim_txn and transaction_id != claim_txn:
raise StorePurchaseRejected("App Store transaction id does not match.")
if not claim_txn:
raise StorePurchaseRejected("App Store transaction id is missing.")
expires_at = _ms_to_dt(claims.get("expiresDate"))
purchased_at = _ms_to_dt(claims.get("purchaseDate"))
revoked = _ms_to_dt(claims.get("revocationDate")) is not None
now = timezone.now()
if revoked or (expires_at is not None and expires_at <= now):
status = UserSubscription.Status.CANCELED
else:
status = UserSubscription.Status.ACTIVE
price_cents = 0
raw_price = claims.get("price")
try:
if raw_price is not None:
price_cents = max(0, int(raw_price) // 10)
except (TypeError, ValueError):
price_cents = 0
currency = str(claims.get("currency") or "usd").lower()
original = str(claims.get("originalTransactionId") or claim_txn)
return VerifiedStorePurchase(
source=UserSubscription.Source.APP_STORE,
store_label="APP_STORE",
product_id=claim_product,
transaction_id=claim_txn,
original_transaction_id=original,
expires_at=expires_at,
purchased_at=purchased_at,
cancel_at_period_end=False,
status=status,
price_cents=price_cents,
currency=currency,
account_token=str(claims.get("appAccountToken") or ""),
)
def verify_google_subscription(
*,
product_id: str,
purchase_token: str,
transaction_id: str = "",
) -> VerifiedStorePurchase:
"""Verify a Play Billing purchase token with the Android Publisher API."""
if not purchase_token:
raise StorePurchaseRejected("Play Store purchases require purchase_token.")
if not product_id:
raise StorePurchaseRejected("Play Store purchases require product_id.")
body = fetch_google_subscription(purchase_token)
return _verified_from_google(
body,
product_id=product_id,
purchase_token=purchase_token,
transaction_id=transaction_id,
)
def fetch_google_subscription(purchase_token: str) -> dict[str, Any]:
"""GET purchases.subscriptionsv2 for this token. Tests patch this."""
package_name = (getattr(settings, "GOOGLE_PLAY_PACKAGE_NAME", "") or "").strip()
raw = (getattr(settings, "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or "").strip()
if not package_name or not raw:
raise StoreNotConfiguredError(
"Google Play verification is not configured "
"(GOOGLE_PLAY_PACKAGE_NAME and GOOGLE_PLAY_SERVICE_ACCOUNT_JSON)."
)
info = _load_service_account_info(raw)
try:
access_token = _google_access_token(info)
url = (
"https://androidpublisher.googleapis.com/androidpublisher/v3/"
f"applications/{quote(package_name, safe='')}/purchases/"
f"subscriptionsv2/tokens/{quote(purchase_token, safe='')}"
)
response = requests.get(
url,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
except (requests.RequestException, ValueError, OSError) as exc:
logger.exception("Play Store verification request failed")
raise StoreVerificationError("Play Store verification failed.") from exc
if response.status_code == 404:
raise StorePurchaseRejected("Play Store purchase was not found.")
if response.status_code >= 400:
logger.error(
"Play Store verification HTTP %s",
response.status_code,
)
if response.status_code >= 500:
raise StoreVerificationError("Play Store verification failed.")
raise StorePurchaseRejected("Play Store rejected this purchase token.")
try:
body = response.json()
except ValueError as exc:
raise StoreVerificationError("Play Store returned an invalid response.") from exc
if not isinstance(body, dict):
raise StoreVerificationError("Play Store returned an invalid response.")
return body
def _verified_from_google(
body: dict[str, Any],
*,
product_id: str,
purchase_token: str,
transaction_id: str,
) -> VerifiedStorePurchase:
line_items = body.get("lineItems") or []
match = None
if isinstance(line_items, list):
for item in line_items:
if isinstance(item, dict) and str(item.get("productId") or "") == product_id:
match = item
break
if match is None:
raise StorePurchaseRejected("Play Store product does not match this purchase.")
state = str(body.get("subscriptionState") or "")
expires_at = _parse_rfc3339(match.get("expiryTime"))
now = timezone.now()
expired = expires_at is not None and expires_at <= now
cancel_at_period_end = False
if state in {"SUBSCRIPTION_STATE_PENDING", "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED"}:
raise StorePurchaseRejected("Play Store purchase is still pending.")
if state in {"SUBSCRIPTION_STATE_ACTIVE", "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"} and not expired:
status = UserSubscription.Status.ACTIVE
elif state == "SUBSCRIPTION_STATE_CANCELED" and not expired:
status = UserSubscription.Status.ACTIVE
cancel_at_period_end = True
elif state == "SUBSCRIPTION_STATE_ON_HOLD":
status = UserSubscription.Status.PAST_DUE
else:
status = UserSubscription.Status.CANCELED
auto_renew = (match.get("autoRenewingPlan") or {}) if isinstance(match, dict) else {}
if isinstance(auto_renew, dict) and auto_renew.get("autoRenewEnabled") is False and not expired:
if status == UserSubscription.Status.ACTIVE:
cancel_at_period_end = True
order_id = str(match.get("latestSuccessfulOrderId") or transaction_id or purchase_token)
identifiers = body.get("externalAccountIdentifiers") or {}
account_token = ""
if isinstance(identifiers, dict):
account_token = str(identifiers.get("obfuscatedExternalAccountId") or "")
price_cents, currency = _google_price(auto_renew if isinstance(auto_renew, dict) else {})
return VerifiedStorePurchase(
source=UserSubscription.Source.PLAY_STORE,
store_label="PLAY_STORE",
product_id=product_id,
transaction_id=order_id,
original_transaction_id=purchase_token,
expires_at=expires_at,
purchased_at=None,
cancel_at_period_end=cancel_at_period_end,
status=status,
price_cents=price_cents,
currency=currency,
account_token=account_token,
)
@transaction.atomic
def grant_verified_store_purchase(user, verified: VerifiedStorePurchase) -> UserSubscription:
plan = resolve_plan_from_revenuecat_product(verified.product_id)
if plan is None:
raise StorePurchaseRejected("This store product is not mapped to a plan.")
expected = store_account_token(user.pk)
if verified.account_token and verified.account_token.lower() != expected.lower():
raise StorePurchaseRejected("Purchase account token does not match this user.")
original = verified.original_transaction_id
if original:
taken = (
UserSubscription.objects.select_for_update()
.filter(revenuecat_original_transaction_id=original)
.exclude(user=user)
.exists()
)
if taken:
raise StorePurchaseRejected(
"This purchase is already linked to another account."
)
sub = assign_plan_from_revenuecat(
user,
plan_slug=plan.slug,
product_id=verified.product_id,
revenuecat_original_transaction_id=original,
status=verified.status,
cancel_at_period_end=verified.cancel_at_period_end,
current_period_end=verified.expires_at,
keep_existing_plan_if_unknown=False,
source=verified.source,
)
if verified.status != UserSubscription.Status.ACTIVE:
return sub
amount = verified.price_cents or plan.price_cents
currency = (verified.currency or plan.currency or "usd").lower()
event_id = f"{verified.source}:{verified.transaction_id}"
invoice, _created = Invoice.objects.update_or_create(
revenuecat_event_id=event_id,
defaults={
"user": user,
"company": getattr(user, "company", None),
"provider": verified.source,
"status": Invoice.Status.PAID,
"currency": currency,
"amount_due": amount,
"amount_paid": amount,
"period_start": verified.purchased_at,
"period_end": verified.expires_at,
"revenuecat_store": verified.store_label,
"description": f"{plan.name} — {verified.store_label}",
"hosted_invoice_url": "",
},
)
Payment.objects.update_or_create(
revenuecat_transaction_id=verified.transaction_id,
defaults={
"user": user,
"company": getattr(user, "company", None),
"invoice": invoice,
"provider": verified.source,
"status": Payment.Status.SUCCEEDED,
"currency": currency,
"amount": amount,
"paid_at": verified.purchased_at or timezone.now(),
"failure_message": "",
},
)
return sub
def _load_apple_roots() -> list[x509.Certificate]:
pems: list[str] = []
extra = (getattr(settings, "APPLE_IAP_ROOT_PEM", "") or "").strip()
if extra:
pems.append(extra)
if _BUNDLED_APPLE_ROOT.is_file():
pems.append(_BUNDLED_APPLE_ROOT.read_text(encoding="utf-8"))
roots: list[x509.Certificate] = []
for pem in pems:
try:
roots.append(x509.load_pem_x509_certificate(pem.encode("utf-8")))
except ValueError:
logger.error("Ignoring an unreadable Apple root certificate")
return roots
def _load_der_cert(value: Any) -> x509.Certificate:
if not isinstance(value, str) or not value.strip():
raise StorePurchaseRejected("App Store certificate is missing.")
try:
der = base64.b64decode(value)
return x509.load_der_x509_certificate(der)
except (ValueError, TypeError) as exc:
raise StorePurchaseRejected("App Store certificate is invalid.") from exc
def _trusted_leaf(
certs: list[x509.Certificate],
roots: list[x509.Certificate],
) -> x509.Certificate:
if not certs:
raise StorePurchaseRejected("App Store certificate chain is empty.")
now = datetime.now(dt_timezone.utc)
for cert in certs:
if cert.not_valid_before_utc > now or cert.not_valid_after_utc < now:
raise StorePurchaseRejected("App Store certificate is not currently valid.")
for index in range(len(certs) - 1):
_verify_signed_by(certs[index], certs[index + 1])
if certs[index].issuer != certs[index + 1].subject:
raise StorePurchaseRejected("App Store certificate issuer mismatch.")
last = certs[-1]
root_fps = {root.fingerprint(hashes.SHA256()) for root in roots}
if last.fingerprint(hashes.SHA256()) in root_fps:
return certs[0]
for root in roots:
try:
_verify_signed_by(last, root)
except Exception:
continue
if last.issuer == root.subject:
return certs[0]
raise StorePurchaseRejected("App Store certificate chain is not trusted.")
def _verify_signed_by(child: x509.Certificate, issuer: x509.Certificate) -> None:
public_key = issuer.public_key()
signature_hash = child.signature_hash_algorithm
if signature_hash is None:
raise StorePurchaseRejected("App Store certificate has no signature hash.")
if isinstance(public_key, ec.EllipticCurvePublicKey):
public_key.verify(
child.signature,
child.tbs_certificate_bytes,
ec.ECDSA(signature_hash),
)
return
public_key.verify(
child.signature,
child.tbs_certificate_bytes,
padding.PKCS1v15(),
signature_hash,
)
def _require_app_store_oid(leaf: x509.Certificate) -> None:
try:
leaf.extensions.get_extension_for_oid(_APP_STORE_CERT_OID)
except x509.ExtensionNotFound as exc:
raise StorePurchaseRejected(
"Signed transaction certificate is not an App Store certificate."
) from exc
def _google_access_token(info: dict[str, Any]) -> str:
"""OAuth JWT bearer grant for a Play Console service account."""
client_email = info.get("client_email")
private_key = info.get("private_key")
token_uri = info.get("token_uri") or "https://oauth2.googleapis.com/token"
if not client_email or not private_key:
raise StoreNotConfiguredError(
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is missing client_email or private_key."
)
now = int(time.time())
assertion = jwt.encode(
{
"iss": client_email,
"scope": _PLAY_SCOPE,
"aud": token_uri,
"iat": now,
"exp": now + 3600,
},
private_key,
algorithm="RS256",
)
try:
response = requests.post(
token_uri,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
},
timeout=15,
)
except requests.RequestException as exc:
raise StoreVerificationError("Play Store verification failed.") from exc
if response.status_code >= 400:
raise StoreVerificationError("Play Store authentication failed.")
try:
token = response.json().get("access_token")
except ValueError as exc:
raise StoreVerificationError("Play Store authentication failed.") from exc
if not token:
raise StoreVerificationError("Play Store authentication failed.")
return str(token)
def _load_service_account_info(raw: str) -> dict[str, Any]:
text = raw
if not raw.lstrip().startswith("{"):
path = Path(raw)
if path.is_file():
text = path.read_text(encoding="utf-8")
try:
info = json.loads(text)
except json.JSONDecodeError as exc:
raise StoreNotConfiguredError(
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not valid JSON."
) from exc
if not isinstance(info, dict):
raise StoreNotConfiguredError(
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be a JSON object."
)
return info
def _ms_to_dt(value: Any) -> datetime | None:
if value is None or value == "":
return None
try:
ms = int(value)
except (TypeError, ValueError):
return None
if ms <= 0:
return None
return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc)
def _parse_rfc3339(value: Any) -> datetime | None:
if not value or not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=dt_timezone.utc)
return parsed
def _google_price(auto_renew: dict[str, Any]) -> tuple[int, str]:
price = auto_renew.get("recurringPrice") if isinstance(auto_renew, dict) else None
if not isinstance(price, dict):
return 0, "usd"
currency = str(price.get("currencyCode") or "usd").lower()
try:
units = int(price.get("units") or 0)
except (TypeError, ValueError):
units = 0
try:
nanos = int(price.get("nanos") or 0)
except (TypeError, ValueError):
nanos = 0
cents = units * 100 + nanos // 10_000_000
return max(0, cents), currency
+51 -1
View File
@@ -2,7 +2,10 @@
from __future__ import annotations
from django.test import TestCase
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
@@ -126,3 +129,50 @@ class RevenueCatDispatchTests(TestCase):
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
@override_settings(REVENUECAT_WEBHOOK_SECRET="test-rc-secret")
class RevenueCatWebhookViewTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.client = APIClient()
self.url = reverse("finance_revenuecat_webhook")
self.user = make_user(email="rcview@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = "hesychia_founders_monthly"
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
def test_missing_secret_config(self):
with override_settings(REVENUECAT_WEBHOOK_SECRET=""):
response = self.client.post(
self.url, {"event": _rc_event()}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthorized(self):
response = self.client.post(
self.url,
{"event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer wrong",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_success(self):
response = self.client.post(
self.url,
{"api_version": "1.0", "event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer test-rc-secret",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
UserSubscription.objects.filter(
user=self.user,
source=UserSubscription.Source.REVENUECAT,
status=UserSubscription.Status.ACTIVE,
).exists()
)
self.assertTrue(
Invoice.objects.filter(
user=self.user, provider=Invoice.Provider.REVENUECAT
).exists()
)
@@ -1,336 +0,0 @@
"""StoreKit and Play Billing confirmation. No store webhooks."""
from __future__ import annotations
import base64
from datetime import datetime, timedelta, timezone as dt_timezone
from unittest.mock import patch
import jwt
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_user
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
from monetization.services.plans import seed_subscription_plans
from monetization.services.store_purchases import (
StoreNotConfiguredError,
StorePurchaseRejected,
confirm_store_purchase,
store_account_token,
)
_APP_STORE_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
_PRODUCT = "hesychia_founders_monthly"
def _cert(*, subject_cn, subject_key, issuer_cert, issuer_key, is_ca, app_store=False):
now = datetime.now(dt_timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject_cn)])
issuer_name = issuer_cert.subject if issuer_cert is not None else name
builder = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(issuer_name)
.public_key(subject_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=30))
)
if is_ca:
builder = builder.add_extension(
x509.BasicConstraints(ca=True, path_length=None),
critical=True,
)
if app_store:
builder = builder.add_extension(
x509.UnrecognizedExtension(_APP_STORE_OID, b"\x05\x00"),
critical=False,
)
signer = issuer_key if issuer_key is not None else subject_key
return builder.sign(signer, hashes.SHA256())
def _chain():
root_key = ec.generate_private_key(ec.SECP256R1())
leaf_key = ec.generate_private_key(ec.SECP256R1())
root = _cert(
subject_cn="Test Apple Root",
subject_key=root_key,
issuer_cert=None,
issuer_key=None,
is_ca=True,
)
leaf = _cert(
subject_cn="Test StoreKit",
subject_key=leaf_key,
issuer_cert=root,
issuer_key=root_key,
is_ca=False,
app_store=True,
)
root_pem = root.public_bytes(serialization.Encoding.PEM).decode("ascii")
return leaf_key, leaf, root, root_pem
def _b64_der(cert) -> str:
der = cert.public_bytes(serialization.Encoding.DER)
return base64.b64encode(der).decode("ascii")
def _signed_transaction(leaf_key, leaf, root, claims):
return jwt.encode(
claims,
leaf_key,
algorithm="ES256",
headers={"x5c": [_b64_der(leaf), _b64_der(root)]},
)
def _future_ms(days=30) -> int:
moment = datetime.now(dt_timezone.utc) + timedelta(days=days)
return int(moment.timestamp() * 1000)
class AppleConfirmTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="apple@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = _PRODUCT
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
self.leaf_key, self.leaf, self.root, self.root_pem = _chain()
def _claims(self, **overrides):
base = {
"bundleId": "ai.hesychia.chat",
"productId": _PRODUCT,
"transactionId": "2000000000001",
"originalTransactionId": "2000000000001",
"appAccountToken": store_account_token(self.user.pk),
"purchaseDate": _future_ms(days=-1),
"expiresDate": _future_ms(days=30),
"price": 10000,
"currency": "USD",
}
base.update(overrides)
return base
def _confirm(self, claims):
token = _signed_transaction(self.leaf_key, self.leaf, self.root, claims)
with override_settings(
APPLE_IAP_BUNDLE_ID="ai.hesychia.chat",
APPLE_IAP_ROOT_PEM=self.root_pem,
):
return confirm_store_purchase(
self.user,
{
"store": "app_store",
"product_id": _PRODUCT,
"transaction_id": claims["transactionId"],
"signed_transaction": token,
},
)
def test_signed_transaction_grants_plan(self):
sub = self._confirm(self._claims())
self.assertEqual(sub.source, UserSubscription.Source.APP_STORE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertEqual(sub.plan.slug, "founders")
self.assertEqual(sub.revenuecat_original_transaction_id, "2000000000001")
invoice = Invoice.objects.get(user=self.user)
self.assertEqual(invoice.provider, Invoice.Provider.APP_STORE)
self.assertEqual(invoice.amount_paid, 1000)
self.assertEqual(invoice.revenuecat_store, "APP_STORE")
payment = Payment.objects.get(user=self.user)
self.assertEqual(payment.provider, Payment.Provider.APP_STORE)
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
def test_repeat_confirm_is_idempotent(self):
claims = self._claims()
self._confirm(claims)
self._confirm(claims)
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 1)
self.assertEqual(Payment.objects.filter(user=self.user).count(), 1)
def test_wrong_account_token_rejected(self):
with self.assertRaises(StorePurchaseRejected):
self._confirm(self._claims(appAccountToken="00000000-0000-0000-0000-000000000000"))
def test_untrusted_chain_rejected(self):
token = _signed_transaction(
self.leaf_key, self.leaf, self.root, self._claims()
)
with override_settings(APPLE_IAP_BUNDLE_ID="ai.hesychia.chat", APPLE_IAP_ROOT_PEM=""):
with self.assertRaises(StorePurchaseRejected):
confirm_store_purchase(
self.user,
{
"store": "app_store",
"product_id": _PRODUCT,
"signed_transaction": token,
},
)
def test_revoked_transaction_cancels(self):
sub = self._confirm(self._claims(revocationDate=_future_ms(days=-1)))
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
self.assertFalse(Payment.objects.filter(user=self.user).exists())
class PlayConfirmTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="play@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = _PRODUCT
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
def _body(self, **overrides):
expiry = (datetime.now(dt_timezone.utc) + timedelta(days=30)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
body = {
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"lineItems": [
{
"productId": _PRODUCT,
"expiryTime": expiry,
"latestSuccessfulOrderId": "GPA.1234-5678",
"autoRenewingPlan": {
"autoRenewEnabled": True,
"recurringPrice": {
"currencyCode": "USD",
"units": "10",
"nanos": 0,
},
},
}
],
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": store_account_token(self.user.pk),
},
}
body.update(overrides)
return body
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_play_token_grants_plan(self, mock_fetch):
mock_fetch.return_value = self._body()
sub = confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-abc",
"transaction_id": "GPA.1234-5678",
},
)
self.assertEqual(sub.source, UserSubscription.Source.PLAY_STORE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertEqual(sub.revenuecat_original_transaction_id, "token-abc")
self.assertFalse(sub.cancel_at_period_end)
invoice = Invoice.objects.get(user=self.user)
self.assertEqual(invoice.provider, Invoice.Provider.PLAY_STORE)
self.assertEqual(invoice.amount_paid, 1000)
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_canceled_but_unexpired_keeps_access(self, mock_fetch):
mock_fetch.return_value = self._body(
subscriptionState="SUBSCRIPTION_STATE_CANCELED"
)
sub = confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-cancel",
},
)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertTrue(sub.cancel_at_period_end)
@patch("monetization.services.store_purchases.fetch_google_subscription")
def test_token_already_linked_to_someone_else(self, mock_fetch):
other = make_user(email="other@example.com")
UserSubscription.objects.create(
user=other,
plan=SubscriptionPlan.objects.get(slug="founders"),
status=UserSubscription.Status.ACTIVE,
source=UserSubscription.Source.PLAY_STORE,
revenuecat_original_transaction_id="token-taken",
)
mock_fetch.return_value = self._body()
with self.assertRaises(StorePurchaseRejected):
confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-taken",
},
)
def test_missing_play_credentials(self):
with override_settings(GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=""):
with self.assertRaises(StoreNotConfiguredError):
confirm_store_purchase(
self.user,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token-abc",
},
)
class StoreConfirmApiTests(APITestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="api@example.com")
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_store_confirm")
def test_requires_auth(self):
self.client.force_authenticate(user=None)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@patch("monetization.views.confirm_store_purchase")
def test_rejected_purchase_is_400(self, mock_confirm):
mock_confirm.side_effect = StorePurchaseRejected("nope")
response = self.client.post(
self.url,
{
"store": "play_store",
"product_id": _PRODUCT,
"purchase_token": "token",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_lapsed_store_period_ends_on_read(self):
plan = SubscriptionPlan.objects.get(slug="founders")
UserSubscription.objects.create(
user=self.user,
plan=plan,
status=UserSubscription.Status.ACTIVE,
source=UserSubscription.Source.APP_STORE,
current_period_end=timezone.now() - timedelta(days=1),
)
response = self.client.get(reverse("finance_subscription_me"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["status"], "canceled")
self.assertTrue(response.data["needs_checkout"])
self.assertEqual(
response.data["store_account_token"],
store_account_token(self.user.pk),
)
+6 -6
View File
@@ -1,12 +1,12 @@
from django.urls import path
from monetization.views import (
ConfirmStorePurchaseView,
CreateBillingPortalSessionView,
CreateCheckoutSessionView,
InvoiceListView,
PaymentListView,
PlanListView,
RevenueCatWebhookView,
StripeWebhookView,
SubscriptionMeView,
)
@@ -42,14 +42,14 @@ urlpatterns = [
SubscriptionMeView.as_view(),
name="finance_subscription_me",
),
path(
"store/confirm/",
ConfirmStorePurchaseView.as_view(),
name="finance_store_confirm",
),
path(
"webhooks/stripe/",
StripeWebhookView.as_view(),
name="finance_stripe_webhook",
),
path(
"webhooks/revenuecat/",
RevenueCatWebhookView.as_view(),
name="finance_revenuecat_webhook",
),
]
+51 -50
View File
@@ -12,7 +12,6 @@ from monetization.serializers import (
InvoiceSerializer,
PaymentSerializer,
PortalSessionSerializer,
StorePurchaseConfirmSerializer,
SubscriptionPlanSerializer,
)
from monetization.services.plans import (
@@ -21,13 +20,10 @@ from monetization.services.plans import (
seed_subscription_plans,
)
from monetization.services.quotas import get_usage_snapshot
from monetization.services.store_purchases import (
StoreNotConfiguredError,
StorePurchaseRejected,
StoreVerificationError,
confirm_store_purchase,
expire_lapsed_store_subscription,
store_account_token,
from monetization.services.revenuecat import (
RevenueCatWebhookAuthError,
dispatch_revenuecat_event,
verify_revenuecat_authorization,
)
from monetization.services.stripe import (
StripeNotConfiguredError,
@@ -198,7 +194,6 @@ class SubscriptionMeView(APIView):
sub = request.user.subscription
except UserSubscription.DoesNotExist:
sub = None
sub = expire_lapsed_store_subscription(sub)
usage = get_usage_snapshot(request.user)
payload = {
@@ -218,52 +213,11 @@ class SubscriptionMeView(APIView):
if sub and sub.current_period_end
else None
),
"store_account_token": store_account_token(request.user.pk),
"usage": usage.to_dict(),
}
return Response(payload)
class ConfirmStorePurchaseView(APIView):
"""Verify a StoreKit or Play Billing purchase and grant the plan.
The app calls this after the native store sheet succeeds. There is no
store webhook.
"""
def post(self, request):
serializer = StorePurchaseConfirmSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
sub = confirm_store_purchase(request.user, serializer.validated_data)
except StorePurchaseRejected as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except StoreNotConfiguredError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except StoreVerificationError as exc:
logger.exception("Store purchase verification failed")
return Response({"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY)
return Response(
{
"source": sub.source,
"status": sub.status,
"plan_slug": sub.plan.slug if sub.plan_id else None,
"cancel_at_period_end": bool(sub.cancel_at_period_end),
"current_period_end": (
sub.current_period_end.isoformat()
if sub.current_period_end
else None
),
"store_account_token": store_account_token(request.user.pk),
},
status=status.HTTP_200_OK,
)
class StripeWebhookView(APIView):
"""Verify Stripe signatures and upsert local invoice/payment rows."""
@@ -312,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)
Generated
+3 -3
View File
@@ -497,11 +497,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.10.23"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]