- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
19 KiB
Story 3.1: Freemium "AI Discovery Pack" Quota Tracking
Status: review
Story
As a business, I want to track Freemium usage against a Redis quota limit, So that I can limit my API cost exposure for free users.
Given a free user triggers an AI request When the system intercepts the request Then the quota is tracked and the UI updates And (NFR-SC2) the Redis-backed check resolves in under 10ms.
Epic Context
Epic 3: The SaaS Commercial Engine (Monetization & API Cost Protection)
Epic Business Value: The core backend logic allowing us to sell the product without bleeding API costs — freemium limits, router fallback, host-pays.
Cross-Story Dependencies:
- Story 3.1 (this story) establishes the quota tracking foundation
- Story 3.2 builds on this with the LLM Router and provider routing
- Story 3.3 adds smart-routing fallback when quota is low
- Story 3.4 (Host-Pays) extends quota tracking to collaborative sessions
- Story 3.5 (BYOK) bypasses quota for users with their own API keys
- Story 3.6 (Stripe) manages tier upgrades
Technical Constraint: (NFR-SC2) Redis-backed entitlement checks must complete in under 10ms.
Acceptance Criteria
- [AC1] When a free user (BASIC tier) makes an AI request, the system checks Redis for current usage count
- [AC2] Each AI feature (semantic_search, auto_tag, auto_title) has its own Redis counter with format:
usage:{userId}:{feature}:{YYYY-MM} - [AC3] Counter increments atomically via Redis INCRBY (not read-modify-write) to avoid race conditions
- [AC4] If counter >= limit, return HTTP 402 with body
{ error: "QUOTA_EXCEEDED", feature, upgradeTier: "PRO", byokConfigured } - [AC5] If counter < limit, allow request to proceed and increment counter asynchronously (fire-and-forget)
- [AC6] Redis keys have 90-day TTL to auto-cleanup (covers grace period for monthly reconciliation)
- [AC7] The Sidebar footer displays a usage gauge component showing Discovery Pack consumption in real-time
Tasks / Subtasks
- Task 1: Add Prisma models for Subscription, UsageLog (AC: #1)
- Subtask 1.1: Add
Subscriptionmodel with tier/stripe fields - Subtask 1.2: Add
UsageLogmodel for PostgreSQL sync target - Subtask 1.3: Create migration file (via
npx prisma generate)
- Subtask 1.1: Add
- Task 2: Create
lib/entitlements.tswith Redis-backedcanUseFeature()(AC: #2, #3, #4)- Subtask 2.1: Implement
getCurrentPeriodKey()returningYYYY-MMformat - Subtask 2.2: Implement Redis key format:
usage:{userId}:{feature}:{YYYY-MM} - Subtask 2.3: Implement atomic
canUseFeature()with < 10ms target (use Redis GET + pipeline INCRBY) - Subtask 2.4: Return
QuotaExceededErrorwhen limit exceeded
- Subtask 2.1: Implement
- Task 3: Create
lib/usage-tracker.tswithtrackFeatureUsage()(AC: #5)- Subtask 3.1: Fire-and-forget Redis pipeline increment
- Subtask 3.2: Include tokensUsed in metadata
- Task 4: Create
/api/usage/currentendpoint (AC: #6)- Subtask 4.1: Return remaining quota for all features for authenticated user
- Task 5: Create
<UsageMeter>UI component for Sidebar footer (AC: #7)- Subtask 5.1: Show progress bar for Discovery Pack (semantic_search: 30 lifetime, auto_tag: 20, auto_title: 10)
- Subtask 5.2: Real-time updates via React Query polling every 30s
- Subtask 5.3: Show "Upgrade to Pro" paywall modal on 402 response
- Task 6: Create CRON sync worker
/api/cron/sync-usage(AC: #6)- Subtask 6.1: Batch sync Redis counters → PostgreSQL UsageLog
- Subtask 6.2: Handle monthly reset (new period = reset counters for that user/feature)
Dev Notes
Project Structure Notes
- Code base:
memento-note/(Next.js app with App Router) - Redis client: Currently
memento-note/lib/rate-limit.tsuses in-memory Map (INSUFFICIENT). This story replaces it with actual Redis. - No existing
lib/entitlements.ts— creates from scratch - No existing
lib/usage-tracker.ts— creates from scratch - Sidebar:
memento-note/components/sidebar.tsxis the anchor for the UsageMeter UI - AI Factory:
memento-note/lib/ai/factory.tshas provider creation logic — quota checks must integrate BEFORE provider resolution
Files to CREATE (NEW):
memento-note/lib/entitlements.ts # Core quota check logic
memento-note/lib/usage-tracker.ts # Track usage via Redis
memento-note/lib/redis.ts # Redis client singleton
memento-note/app/api/usage/current/route.ts # GET current quota
memento-note/app/api/cron/sync-usage/route.ts # CRON sync Redis→PG
memento-note/components/usage-meter.tsx # UI component
Files to MODIFY (UPDATE):
memento-note/prisma/schema.prisma # Add Subscription, UsageLog models
memento-note/components/sidebar.tsx # Add UsageMeter to footer
memento-note/middleware.ts # Add 402 handling for quota
memento-note/app/api/chat/route.ts # Add canUseFeature() before AI call
Testing Standards
- Unit tests for
canUseFeature()with mocked Redis - Unit tests for
trackFeatureUsage()with Redis pipeline verification - Integration tests for 402 response flow
- UI tests: verify UsageMeter renders correct progress
Dev Agent Guardrails
Technical Requirements
- Redis client: Use
@upstash/redisfor server-side queries (NOT client-side SDK). Self-hosted Redis viadocker-compose.yml(seesaas-deployment-prep.mdSection I). - NFR-SC2 (< 10ms): Use Redis GET + pipeline INCRBY — no read-modify-write patterns.
- Atomic counters: Always use
redis.pipeline().incrby()notGET → compute → SET. - Async tracking:
trackFeatureUsage()must be fire-and-forget — do NOT await in the hot path. - Period key: Use
new Date().toISOString().slice(0, 7)forYYYY-MMformat — UTC, not local.
Architecture Compliance
- Starter Pack limits (from saas-deployment-prep.md):
- BASIC:
semanticSearch: 30, autoTag: 20, autoTitle: 10(lifetime, not monthly) - PRO:
semanticSearch: 100, autoTag: 200, autoTitle: 200, reformulate: 50, chat: 100(monthly) - BUSINESS:
semanticSearch: 1000, autoTag: 1000, autoTitle: 1000, reformulate: 500, chat: 1000(monthly)
- BASIC:
- Redis key format:
usage:{userId}:{feature}:{YYYY-MM}with 90-day TTL - CRON sync interval: Every 5 minutes via Vercel Cron or node-cron
- PostgreSQL sync: Use UPSERT (INSERT ... ON CONFLICT UPDATE) via Prisma
$upsert()
Library / Framework Requirements
- Redis:
docker-compose.ymlwithredis:7-alpine(self-hosted, no Upstash cost) - Prisma: Already in use (
@prisma/client@5.22.0) - React Query: Already in use (
@tanstack/react-query@5.100.9) for UsageMeter polling - AI SDK: Already in use (
ai@6.0.23) for provider calls
File Structure Requirements
- Follow existing patterns in
memento-note/lib/— TypeScript files, no default exports - Use
import { redis } from '@/lib/redis'singleton pattern (see existinglib/prisma.ts) - API routes follow Next.js App Router:
app/api/[resource]/route.ts
Testing Requirements
- Use
vitest(already configured) - Mock Redis with
vi.mock('@upstash/redis') - No database tests — mock Prisma with
vi.mock('@prisma/client') - Target 80% coverage for
entitlements.ts
Previous Story Intelligence
N/A — This is the first story in Epic 3.
Git Intelligence Summary
Last 5 commits on modified paths:
| Commit | Change |
|---|---|
195e845 |
security: fix SQL injection in semantic search - use parameterized queries with bind params |
ff664f7 |
fix: add missing await on reciprocalRankFusion call |
41596c2 |
fix: openrouter provider fallback to CUSTOM_OPENAI_API_KEY |
cf2786d |
feat: migrate semantic search to pgvector + full-text search |
330c0c6 |
feat: integrate Google Gemini, MiniMax, and GLM providers |
Key insight: Recent commits show security hardening on SQL queries — quota tracking must use parameterized queries for any SQL, and Redis must be used for the fast path.
Latest Technical Information
Redis Self-Hosted (from saas-deployment-prep.md):
# docker-compose.yml
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
ports:
- "127.0.0.1:6379:6379"
@upstash/redis package is NOT in package.json — use ioredis instead (already a dependency of socket.io):
// lib/redis.ts
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST ?? 'localhost',
port: parseInt(process.env.REDIS_PORT ?? '6379'),
password: process.env.REDIS_PASSWORD,
lazyConnect: true,
});
export { redis };
Subscription tiers defined in saas-deployment-prep.md Section B:
- BASIC: free with "AI Discovery Pack" (lifetime limits)
- PRO: €9.90/month (monthly limits)
- BUSINESS: €29.90/month (higher monthly limits)
- ENTERPRISE: €49.90 + €3.90/user/month
Project Context Reference
- PRD:
docs/prd.md— Product Requirements Document with full business context - Architecture:
memento-note/docs/saas-deployment-prep.md— V3 SaaS architecture including Redis quota system - BYOK/Billing:
memento-note/docs/byok-billing-patch-v3.md— Full technical spec for quota + BYOK + host-pays - UX Spec:
docs/ux-design-specification.md— Usage meter in sidebar footer (Section: Emplacement Quotas) - Epics:
docs/epics.md— Epic 3 with Story 3.1 requirements
Dev Agent Record
Agent Model Used
Claude Opus 4.7
Debug Log References
- TypeScript errors fixed: removed type arguments from ioredis
get()calls (line 105, 147 in entitlements.ts, line 37-38 in usage-tracker.ts) - Added
@types/ioredisdev dependency for TypeScript support - Changed
SubscriptionTierfrom Prisma enum import to local type alias to avoid import conflicts
Completion Notes List
- ✅ Added
Subscription,UsageLog,FeatureFlagPrisma models to schema - ✅ Created
lib/redis.tswith ioredis client singleton - ✅ Created
lib/entitlements.tswithcanUseFeature(),checkEntitlementOrThrow(),getUserQuotas(),QuotaExceededErrorclass - ✅ Created
lib/usage-tracker.tswith fire-and-forgettrackFeatureUsage()andgetUsageCounter() - ✅ Created
/api/usage/currentGET endpoint returning user quotas - ✅ Created
/api/cron/sync-usagePOST endpoint for Redis → PostgreSQL sync - ✅ Created
UsageMeterUI component with React Query 30s polling - ✅ Integrated
UsageMeterinto sidebar footer - ✅ Added unit tests for entitlements.ts (9 tests passing)
File List
NEW:
memento-note/lib/redis.ts— Redis client singleton using ioredismemento-note/lib/entitlements.ts— Core quota check logic with Redismemento-note/lib/usage-tracker.ts— Fire-and-forget usage trackingmemento-note/app/api/usage/current/route.ts— GET current quota endpointmemento-note/app/api/cron/sync-usage/route.ts— CRON sync workermemento-note/components/usage-meter.tsx— UI usage meter componentmemento-note/tests/unit/entitlements.test.ts— Unit tests
MODIFIED:
memento-note/prisma/schema.prisma— Added Subscription, UsageLog, FeatureFlag models; addedsubscriptionandusageLogsrelations to Usermemento-note/components/sidebar.tsx— Added UsageMeter import and integration to footer
Story Completion Status
- Status: in-progress
- Completion Note: Re-review patches applied. All decisions resolved, all patches fixed. 17 tests passing.
- Story ID: 3.1
- Story Key: 3-1-freemium-quota-tracking
Review Findings (Re-Review — 2026-05-15)
Decision Needed — Resolved
- [Review][Decision → Patch] Quota consommé avant l'appel IA — résolu : passage en async fire-and-forget (
canUseFeaturecheck-only +incrementUsageAsync), conformément à AC5. - [Review][Decision → Patch] PAST_DUE/CANCELED perdent le tier immédiatement — résolu : grace period via
currentPeriodEnddansgetEffectiveTier(). - [Review][Decision → Patch] Quota guard uniquement sur chat — résolu : guards ajoutés sur
semantic_search(semantic-search.ts),auto_tag(tags/route.ts),auto_title(title-suggestions/route.ts). - [Review][Decision → Patch] Feature names camelCase vs snake_case — résolu : conversion en snake_case (
semantic_search,auto_tag,auto_title,reformulate,chat). - [Review][Decision → Resolved] BASIC lifetime vs mensuel — résolu : reset mensuel pour tous les tiers (meilleur pour le marketing, moins frustrant).
Patch — Applied
- [Review][Patch] Redis down → 500 sur toutes les features [entitlements.ts] — Fixed : fail-open dans
canUseFeature()etgetUserQuotas(), erreurs loggées. - [Review][Patch] /api/usage/current avale toutes les erreurs en 200 [usage/current/route.ts] — Fixed : retourne 503 avec message d'erreur explicite.
- [Review][Patch] SCAN pattern corrompu par userIds contenant la période [sync-usage/route.ts] — Fixed : parsing robuste via
parseUsageKey()avec extraction depuis la fin de la clé. - [Review][Patch] TTL réinitialisé à chaque incrément [entitlements.ts] — Fixed : Lua script vérifie
TTL == -1avant de set EXPIRE (uniquement sur création). - [Review][Patch] BASIC features sans limit → message confus [entitlements.ts] — Fixed : nouveau reason
FEATURE_NOT_AVAILABLEavec message clair et upgradeTier. - [Review][Patch] parseInt tronque incrbyfloat décimales [quota-utils.ts] — Fixed :
Number()+Math.round()au lieu deparseInt(). - [Review][Patch] Utilisateur supprimé pendant sync → abort tout le batch [sync-usage/route.ts] — Fixed : try/catch individuel par clé, compteur d'erreurs séparé.
- [Review][Patch] usedQuota incorrect dans QuotaExceededError [entitlements.ts] — Fixed : calcul basé sur
result.limit - result.remainingavec garde.
Deferred
- [Review][Defer] Sync CRON N+1 unbatched Prisma upserts — deferred, optimisation perf
- [Review][Defer] Pas de vérification d'autorisation sur conversationId/notebookId — deferred, pré-existant hors scope
- [Review][Defer] Race de période à minuit UTC — deferred, inhérent au billing mensuel
- [Review][Defer] byokConfigured toujours false — deferred, Story 3.5 (BYOK)
Review Findings
Decision Needed
- [Review][Decision → Patch] Quota check wired via middleware.ts with AI route filtering — resolved: global interception via middleware with path matching for AI routes. Converts to patch.
- [Review][Decision → Resolved] BASIC lifetime vs monthly key format — resolved: monthly reset accepted for all tiers including BASIC.
- [Review][Decision → Patch] HTTP 402 error handler — resolved: dedicated error handler in middleware that catches QuotaExceededError and returns 402 with required body. Converts to patch.
- [Review][Decision → Patch] Sidebar color changes — resolved: separate into distinct commit. Converts to patch.
Patch
- [Review][Patch] Race condition: check-then-increment not atomic [entitlements.ts] — Fixed: atomic Lua script
checkAndConsume()for check+increment in single Redis call. - [Review][Patch] Unauthenticated cron endpoint [app/api/cron/sync-usage/route.ts] — Fixed: added
verifyCronAuth()with Bearer token check matching other cron endpoints. - [Review][Patch] KEYS command on production Redis [app/api/cron/sync-usage/route.ts] — Fixed: replaced with
scanKeys()using SCAN cursor-based iteration. - [Review][Patch] TTL not applied to tokens sub-key [usage-tracker.ts] — Fixed: added
.expire(\${key}:tokens`, TTL_SECONDS)` in pipeline. - [Review][Patch] parseInt NaN on non-numeric Redis values [entitlements.ts] — Fixed: extracted
parseRedisInt()utility with NaN guard. - [Review][Patch] getUserQuotas sequential Redis calls [entitlements.ts] — Fixed: replaced loop with
redis.mget()single round trip. - [Review][Patch] Subscription status ignored [entitlements.ts] — Fixed:
getEffectiveTier()checks ACTIVE/TRIALING status, falls back to BASIC for INACTIVE/PAST_DUE/CANCELED. - [Review][Patch] getUserTier called twice in checkEntitlementOrThrow [entitlements.ts] — Fixed:
checkAndConsume()returns tier in result, single DB read. - [Review][Patch] Redis singleton no reconnection after 3 failures [redis.ts] — Fixed: increased max retries to 10, added 30s backoff, error/connect event handlers, global singleton pattern.
- [Review][Patch] Missing paywall modal (Task 5.3) [usage-meter.tsx] — Fixed: added "Upgrade to Pro" modal with pricing, feature list, and CTA button.
- [Review][Patch] UsageMeter hardcodes BASIC limits [usage-meter.tsx] — Fixed: uses
data[f.key].limitfrom API response for dynamic tier-aware display. - [Review][Patch] UI shows "Infinity left" for ENTERPRISE [usage-meter.tsx] — Fixed:
formatRemaining()returns "Unlimited" for Infinity values, "∞" for individual features. - [Review][Patch] Duplicate utility functions across 3 files — Fixed: extracted to shared
lib/quota-utils.tsmodule. - [Review][Patch] tokensUsed no negative validation [usage-tracker.ts] — Fixed: early return with warning for negative values.
- [Review][Patch] Quota check wired into chat route [app/api/chat/route.ts] — Fixed:
checkEntitlementOrThrow()called before AI processing, returns 402 withQuotaExceededError.toJSON(). - [Review][Patch] Sidebar color changes separated — Fixed: reverted unrelated
blueprint→memento-terreocolor changes from sidebar.tsx diff.
Deferred
- [Review][Defer] Fire-and-forget tracking can lose data [usage-tracker.ts:19-27] — deferred, by design per AC5 (cron sync compensates)
- [Review][Defer] Redis key parsing with colons in userId [sync-usage/route.ts:19-22] — deferred, CUIDs don't contain colons
- [Review][Defer] FeatureFlag model unused [schema.prisma:696-703] — deferred, may be needed for future stories
- [Review][Defer] metadata field untyped String [schema.prisma] — deferred, pre-existing Prisma pattern
- [Review][Defer] Period boundary edge cases — deferred, inherent to monthly billing, standard UTC approach
- [Review][Defer] Subscription requires period fields with no defaults [schema.prisma:664-665] — deferred, Story 3.6 territory
- [Review][Defer] ENTERPRISE tier not in guardrails table — deferred, works as unlimited in TIER_LIMITS
Change Log
| Date | Change |
|---|---|
| 2026-05-14 | Initial implementation — Added Subscription/UsageLog Prisma models, created entitlements.ts, usage-tracker.ts, redis.ts, API endpoints, UsageMeter UI component, and unit tests |
| 2026-05-15 | Re-review: async fire-and-forget, grace period PAST_DUE/CANCELED, snake_case features, fail-open Redis, quota guards on all AI routes, robust key parsing, TTL on creation only, FEATURE_NOT_AVAILABLE reason, Math.round for floats, individual sync error handling |