feat(mobile): add 3-tier plan display and chat quota paywall#6833
feat(mobile): add 3-tier plan display and chat quota paywall#6833
Conversation
Changes the default NEO_CHAT_QUESTIONS_PER_MONTH from 200 to 2000 to match the new plan tier pricing structure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extends the PricingOption model with plan_id (tier identifier), subtitle (e.g. "2000 questions per month"), and eyebrow (e.g. "Most popular") fields. These enable the mobile app to group and display multiple plan tiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New Dart model to parse the /v1/users/me/usage-quota API response, tracking plan type, usage amount, quota limit, and reset time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds API call to fetch user chat usage quota from /v1/users/me/usage-quota endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pre-checks chat quota before sending messages and exposes isChatQuotaExceeded state for UI to show paywall. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Shows usage info, reset timer, and upgrade button when chat quota is exceeded. Launches PlansSheet for plan selection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Checks quota on page load and before/after sending messages, triggering the paywall sheet when exceeded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces single-plan yearly/monthly cards with a tier selector showing Neo, Operator, and Architect plans with billing period toggle. Falls back to legacy display for single-tier responses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds a 3-tier plan selector (Neo/Operator/Architect) to the plans sheet with a billing period toggle, and introduces a chat quota paywall that pre-checks
Confidence Score: 3/5Two P1 issues should be addressed before merging: voice quota bypass and the in-function import. The voice message quota bypass is a present functional defect — the enforcement guarantee is incomplete. The in-function import violates an explicit project rule. Both are straightforward to fix but represent real gaps in the feature as shipped. app/lib/providers/message_provider.dart (voice path missing quota check) and backend/routers/payment.py (in-function import) Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant ChatPage
participant MessageProvider
participant BackendAPI
User->>ChatPage: Open chat tab
ChatPage->>MessageProvider: checkChatQuota()
MessageProvider->>BackendAPI: GET /v1/users/me/usage-quota
BackendAPI-->>MessageProvider: ChatUsageQuota {allowed, used, limit, resetAt}
User->>ChatPage: Type and send text message
ChatPage->>MessageProvider: isChatQuotaExceeded?
alt quota exceeded
MessageProvider-->>ChatPage: true
ChatPage->>User: Show ChatQuotaPaywall bottom sheet
User->>ChatPage: Tap Upgrade Plan
ChatPage->>User: Show PlansSheet 3-tier selector
else quota ok
ChatPage->>MessageProvider: sendMessageStreamToServer(text)
MessageProvider->>BackendAPI: GET /v1/users/me/usage-quota pre-check
BackendAPI-->>MessageProvider: ChatUsageQuota
MessageProvider->>BackendAPI: POST /v2/messages stream
BackendAPI-->>MessageProvider: SSE chunks
MessageProvider-->>ChatPage: notifyListeners()
ChatPage->>MessageProvider: isChatQuotaExceeded? post-send recheck
end
Note over User,BackendAPI: Voice path has NO quota check P1 gap
User->>ChatPage: Send voice message
ChatPage->>MessageProvider: sendVoiceMessageStreamToServer(bytes)
MessageProvider->>BackendAPI: POST /v1/messages/voice no quota check
|
| class ChatQuotaPaywall extends StatelessWidget { | ||
| final ChatUsageQuota quota; | ||
| final UsageProvider usageProvider; | ||
|
|
||
| const ChatQuotaPaywall({ | ||
| super.key, | ||
| required this.quota, | ||
| required this.usageProvider, | ||
| }); |
There was a problem hiding this comment.
usageProvider field is declared but never used
The usageProvider parameter is required and passed by the caller, but the widget body never references it. _PlansSheetWrapper gets the provider directly from context. Remove the field to avoid misleading callers into believing it has an effect.
| class ChatQuotaPaywall extends StatelessWidget { | |
| final ChatUsageQuota quota; | |
| final UsageProvider usageProvider; | |
| const ChatQuotaPaywall({ | |
| super.key, | |
| required this.quota, | |
| required this.usageProvider, | |
| }); | |
| class ChatQuotaPaywall extends StatelessWidget { | |
| final ChatUsageQuota quota; | |
| const ChatQuotaPaywall({ | |
| super.key, | |
| required this.quota, | |
| }); |
| }); | ||
|
|
||
| // Auto-select first tier if none selected | ||
| selectedTierId ??= sortedTierIds.first; |
There was a problem hiding this comment.
State mutated without
setState during build
selectedTierId ??= sortedTierIds.first assigns a state variable inside _buildTierPlanCards, which is called during build. Mutating state without setState during a build pass can suppress the rebuilds needed for dependents to reflect the change consistently. Move the initial selection to initState or set it lazily via WidgetsBinding.instance.addPostFrameCallback.
| fontWeight: FontWeight.w600, | ||
| ), | ||
| ), | ||
| if (eyebrow != null && eyebrow == 'Most popular') ...[ |
There was a problem hiding this comment.
…ENABLED The /v1/users/me/usage-quota endpoint was computing allowed=false when usage exceeded the limit regardless of the kill-switch, causing the mobile client to enforce caps even when ops left enforcement off. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When opening the plans sheet, initializes selectedTierId from the user's active subscription tier rather than always defaulting to the first tier (unlimited/Neo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…essages Moves the quota check to a fresh API call before addMessageLocally() so that a stale cached quota never results in a visible message that was never sent to the backend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Voice messages via sendVoiceMessageStreamToServer were bypassing the quota gate. Now checks quota before sending voice messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Moves filter_plans_for_user, should_show_new_plans, and adapt_plans_for_legacy_client imports to module top level per repo convention of no in-function imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Moves the remaining in-function 'from datetime import datetime' to module top level. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests fromJson parsing, display getters, boundary conditions, and default handling for the chat quota model. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of silently dropping 402 responses, makeStreamingApiCall and
makeMultipartStreamingApiCall now yield error:402:{body} so callers
can detect and handle quota exceeded errors from the backend.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Handle error:402: prefix from streaming responses to surface quota exceeded errors to the UI instead of silently failing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Quota exceeded is now detected from the 402 error code returned by the chat streaming endpoint, making the separate pre-check unnecessary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… MessageProvider Remove checkChatQuota() pre-check method. Add _tryParseQuotaError() to detect quota exceeded from the 402 response body returned inline during chat message streaming. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Quota enforcement now happens via 402 error from the streaming endpoint rather than a separate API call before sending. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add fetchChatQuota() to load chat usage quota from the usage-quota API endpoint for display on the usage page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Display chat message usage alongside listen minutes, words, insights, and memories in the monthly usage tab. Shows progress bar and quota limits for plans with chat caps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Scans each user's messages collection and reconciles actual chat message count against llm_usage tracking. Creates corrective llm_usage entries for any discrepancies. Supports dry-run mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Voice messages via /v2/voice-messages now call enforce_chat_quota(uid) to prevent capped users from bypassing limits through voice input. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reset _chatQuota to null at the start of sendMessageStreamToServer and sendVoiceMessageStreamToServer to prevent false paywall triggers after upgrading or monthly reset. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pass captured now timestamp to get_monthly_chat_usage and use it for the doc_id to prevent month boundary issues if the script runs across UTC midnight. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add localization keys for chat quota card (usage page) and paywall dialog: chatTitle, chatMessages, chatLimitReachedTitle, upgradePlan, maybeLater, resetsInDays/Hours, etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review Cycle 1 — Fixes AppliedAddressed all 4 items from the code review: High: Voice chat quota bypass
High: Sticky quota state causing false paywalls
Medium: Migration timestamp instability across UTC midnight
Low: Hardcoded English strings
Tests: 426 pass, 1 pre-existing flaky (waveform timer) by AI for @beastoin |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review Cycle — Iteration 2 FixesAddressed remaining feedback from CODEx review iteration 2: Changes
Commits
by AI for @beastoin |
…phantom messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hanges Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ocales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review Cycle — Iteration 3 FixesAddressed CODEx review iteration 3 findings: Changes
Known limitation (deferred)
Commits
by AI for @beastoin |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…API English strings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… keys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review Cycle — Iteration 4+5 FixesChanges
Scope noteThe remaining hardcoded English strings in by AI for @beastoin |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…S_PER_MONTH env var Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CP9A — L1 Live Test (Standalone) ✅Backend L1Build: PASS — started on port 10151 with dev .env
App L1Build: PASS — Flutter dev flavor on emulator-5560 (kelvin-dev AVD)
Changed-Path Coverage Checklist
L1 SynthesisBackend L1 proves P1-P4, P13-P14: usage-quota endpoint returns correct quota JSON, chat streaming works for users within quota, available-plans returns plan definitions, and all 13+1 unit tests pass. App L1 proves P5-P6, P8-P9, P11, P15: Flutter dev build compiles and runs, chat page renders, usage page renders with tabs, settings accessible. Paths P7, P10, P12 require backend+app integration (L2) to exercise the 402 paywall flow. by AI for @beastoin |
CP9B — L2 Live Test (Integrated: Backend + App) ✅Setup
Endpoints Hit by App (from backend logs)
Integrated Flows Verified1. Usage Page with Chat Quota (P3, P8, P11)
2. Plans Sheet Tier Display (P4, P9)
3. Chat with Quota Enforcement (P1, P2, P6, P10, P12)
Updated Changed-Path Coverage Checklist (L2)
L2 SynthesisL2 proves P1-P6, P8-P9, P11, P13-P15 in integrated mode: the app fetches quota data from the local backend ( by AI for @beastoin |
Per manager feedback, keep Neo at 200 instead of 2000. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Closes #6830
PricingOptionwithplan_id,subtitle,eyebrowfields for multi-tier plan display. Updates Neo chat cap from 200 to 2000 questions/month. Gates quotaallowedfield byCHAT_CAP_ENFORCEMENT_ENABLEDkill-switch.get_chat_quota_snapshotandenforce_chat_quota, plus 1 existing test fix.Review Cycle Changes (CP7)
Test Plan
Backend Unit Tests (13 tests — all pass)
test_chat_quota.py: Neo below/at/above cap, Architect cost-based, basic plan limits, operator boundary, enforcement disabled/enabled/exceededApp Unit Tests (209 tests)
chat_quota_test.dart: ChatQuota model parsing, display helpers, edge casesL1 Live Test — Standalone ✅
/usage-quota(200 OK),/v2/messages(streaming works),/available-plans(200 OK)L2 Live Test — Integrated (Backend + App) ✅
API_BASE_URL=http://10.0.2.2:8700/POST /v2/messages→ 200 OK → AI response "hey! what's up?" streamed backenforce_chat_quotapassed silently for in-quota userPaths Not Live-Tested (covered by unit tests)
🤖 Generated with Claude Code