Multi-plan subscriptions, quotas, and token usage APIs (#16 #17 #36) (#37)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 9s
Deploy Beta / docker (push) Successful in 18s
Deploy Beta / deploy-beta (push) Successful in 46s

## Summary
Implements [#16](#16), [#17](#17), and [#36](#36) in one backend PR.

- **#36 Multi-plan catalog**: Founders ($10, public), Standard ($15), Pro ($40), Business ($99), Backer ($0). Future tiers seeded but hidden/`is_selectable=false`. Backer email whitelist auto-assigns Founders-level access with no checkout.
- **#36 Feature + prompt gating**: plan feature flags (text vs image); rolling **6h** prompt windows (100 / 200 / 300 / 300 / 300). Enforced in both chat consumers when `ENFORCE_SUBSCRIPTION_GATES=true`.
- **#17 Token-period quotas**: optional `monthly_token_quota` on plans + per-user override; calendar-month aggregation from `PromptMetric`; warn/block when reported token totals exceed cap. Null provider usage never fabricated as 0; tracked via `turns_missing_token_usage`.
- **#16 Token API exposure**: `tokens_in` / `tokens_out` on conversation + prompt serializers (null when unknown). `GET /api/finance/subscription/` returns plan + usage snapshot for the FE.
- Checkout defaults to **Founders**; Stripe paid webhooks assign Founders. Registration/OAuth redeem Backer whitelist and return `needs_checkout`.

Companion FE PR: `chat_web_app` branch `feature/plans-quotas-token-usage`.

## Test plan
- [ ] `manage.py migrate` seeds five plans; admin can add Backer emails
- [ ] Public `GET /api/finance/plans/` returns only Founders
- [ ] Register with Backer email → active Backer, `needs_checkout=false`, checkout rejected
- [ ] Founders checkout + paid webhook → active Founders subscription
- [ ] Chat turn blocked without subscription / when prompt window exceeded / when token period exceeded
- [ ] Standard plan denies image feature; Pro/Founders/Backer allow
- [ ] Conversation/prompt API returns `null` tokens when unreported, sums when present
- [ ] `finance.tests.test_plans_quotas` + existing finance/checkout tests passReviewed-on: #37
This commit was merged in pull request #37.
This commit is contained in:
westfarn committed 2026-07-31 04:24:20 -07:00
1 parent 67f16565e9
commit 841c0962d9
23 files changed
+1577 -36

No files matched your search

+93 -4
View File
@@ -22,7 +22,13 @@ 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 .utils import has_usable_user_prompt, is_heartbeat_payload, normalize_user_message
from .utils import (
extract_token_usage,
has_usable_user_prompt,
is_heartbeat_payload,
normalize_user_message,
)
from finance.services.quotas import FeatureNotAllowed, QuotaExceeded, check_generation_allowed
logger = logging.getLogger(__name__)
@@ -40,6 +46,35 @@ def create_conversation(prompt, email, title):
conversation.save()
return conversation.id
@database_sync_to_async
def resolve_chat_user(email=None, conversation_id=None):
if email:
user = CustomUser.objects.filter(email__iexact=email).first()
if user:
return user
if conversation_id:
conversation = (
Conversation.objects.select_related("user")
.filter(id=conversation_id)
.first()
)
if conversation and conversation.user_id:
return conversation.user
return None
@database_sync_to_async
def enforce_generation_gates(user, feature="text_generation"):
return check_generation_allowed(user, feature=feature)
@database_sync_to_async
def enforce_feature_gate(user, feature):
from finance.services.quotas import assert_feature_allowed
assert_feature_allowed(user, feature)
@database_sync_to_async
def get_workspace(conversation_id):
conversation = Conversation.objects.get(id=conversation_id)
@@ -172,6 +207,7 @@ class ChatState(TypedDict):
response_generator: Any # AsyncGenerator or dict
error: Union[str, None]
model_name: str
chat_user: Any
# --- LangGraph Nodes ---
@@ -210,10 +246,22 @@ async def generation_node(state: ChatState) -> ChatState:
decoded_file = state.get("decoded_file")
file_type = state.get("file_type")
# Feature Flag: Image Generation
# Feature Flag + plan gate: Image Generation
if prompt_type == PromptType.IMAGE_GENERATION:
if not getattr(settings, "ALLOW_IMAGE_GENERATION", False):
return {"response_generator": {"type": "text", "content": "Image Generation is disabled."}}
chat_user = state.get("chat_user")
if chat_user is not None:
try:
await enforce_feature_gate(chat_user, "image_generation")
except FeatureNotAllowed as exc:
return {
"response_generator": {
"type": "error",
"code": exc.code,
"content": exc.message,
}
}
return {"response_generator": {"type": "text", "content": "Image Generation is not supported at this time, but it will be soon."}}
# Feature Flag: Internet Access
@@ -312,6 +360,36 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
)
return
chat_user = await resolve_chat_user(
email=email, conversation_id=conversation_id
)
if chat_user is None:
await self.send_json_message(
json.dumps(
{
"type": "error",
"code": "user_not_found",
"content": "Unable to resolve user for this chat session.",
}
)
)
return
try:
await enforce_generation_gates(chat_user, feature="text_generation")
except (QuotaExceeded, FeatureNotAllowed) as exc:
await self.send_json_message(
json.dumps(
{
"type": "error",
"code": exc.code,
"content": exc.message,
"details": getattr(exc, "details", {}),
}
)
)
return
if not conversation_id:
title = await title_generator.generate_async(message)
conversation_id = await create_conversation(message, email, title)
@@ -357,7 +435,8 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
"prompt_type": None,
"response_generator": None,
"error": None,
"model_name": model
"model_name": model,
"chat_user": chat_user,
}
print("Initial State: ", initial_state)
@@ -387,4 +466,14 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
await self.send("END_OF_THE_STREAM_ENDER_GAME_42")
await save_generated_message(conversation_id, full_response)
await finish_prompt_metric(prompt_metric, len(full_response))
tokens_in, tokens_out = extract_token_usage(
response_generator_or_dict
if isinstance(response_generator_or_dict, dict)
else None
)
await finish_prompt_metric(
prompt_metric,
len(full_response),
tokens_in=tokens_in,
tokens_out=tokens_out,
)