--- title: 'Subscription & Quota Admin Management' type: 'feature' created: '2026-06-20' status: 'done' baseline_commit: '5b13a88b726c03ca6ff5603d901147448a72adbc' context: - 'memento-note/lib/entitlements.ts' - 'memento-note/lib/config.ts' - 'memento-note/lib/billing/stripe-prices.ts' --- ## Intent **Problem:** Subscription tiers, AI quotas, and Stripe business config are split between hardcoded code (`TIER_LIMITS`), server `.env` (price IDs, billing flag), and a minimal admin override (tier dropdown only). Operators cannot adjust limits or billing settings without redeploy; quota enforcement has a TOCTOU race (`check` then `increment`); token analytics are partially broken. **Approach:** Add a secure admin **Billing & Quotas** console backed by DB (`PlanEntitlement` + `SystemConfig`), keep Stripe secrets in infra env only, unify quota enforcement on atomic `reserveUsageOrThrow`, and expose usage dashboards with audit logging for manual tier overrides. ## Boundaries & Constraints **Always:** - `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` stay in server env / secrets vault — never in `SystemConfig`, never editable from browser. - Stripe remains authoritative for paid subscriptions; webhooks drive state; manual admin tier changes are support overrides only. - Quota gating unit stays **requests per feature per calendar month** (not token-based blocking). - Redis quota checks fail-open with structured error logging (industry standard for AI rate limits). - All admin mutations require `role === ADMIN`; i18n for new UI strings in `locales/en.json` and `locales/fr.json` minimum. - Non-destructive DB migration only; seed `PlanEntitlement` from current `TIER_LIMITS` defaults. **Ask First:** - Adding new Prisma models beyond `PlanEntitlement` (e.g. full `FeatureFlag` wiring). - Changing Stripe products/prices in live Stripe Dashboard (ops action, not code). - Switching to usage-based Stripe Meter / Metronome billing. **Never:** - Store `sk_*` or `whsec_*` in plaintext DB or admin forms. - Run `prisma migrate reset`, `db push --accept-data-loss`, or any destructive DB command. - Replace Redis real-time counters with synchronous PostgreSQL writes on every AI request. - Add automated tests unless explicitly requested later. ## I/O & Edge-Case Matrix | Scenario | Input / State | Expected Output / Behavior | Error Handling | |----------|--------------|---------------------------|----------------| | Admin saves tier limit | ADMIN edits PRO `chat` limit 50→75 | `PlanEntitlement` upserted; next `getLimit()` returns 75; existing Redis counters unchanged until period rollover | 403 if non-admin; 400 if invalid feature or negative limit | | Admin saves Stripe price ID | ADMIN sets `STRIPE_PRICE_PRO_MONTHLY` in SystemConfig | `resolvePriceId('PRO','month')` reads DB value; env used only as fallback | 400 if empty when billing enabled | | User at quota | User on BASIC, 30/30 `semantic_search` used | `reserveUsageOrThrow` returns 402 `QUOTA_EXCEEDED` | Fail-open if Redis down (allow + log alert) | | Concurrent AI requests | Two requests at limit−1 | Only one succeeds; second gets 402 (atomic Lua) | N/A | | Admin manual tier override | ADMIN sets user to BUSINESS | `Subscription` upserted; `AuditLog` entry with actor, target, old/new tier | Cannot override own tier (existing rule) | | Billing disabled | `BILLING_ENABLED=false` in SystemConfig | `/settings/billing` shows disabled state; checkout buttons hidden | N/A | | suggest-charts usage | Successful chart suggestion | `incrementUsageAsync` called once (not broken 4-arg call) | N/A | ## Code Map - `memento-note/prisma/schema.prisma` — add `PlanEntitlement` model (`tier`, `feature`, `limitValue`; unique on `[tier, feature]`; `null` limitValue = unlimited; omit row or sentinel = feature unavailable per tier) - `memento-note/prisma/migrations/*` — additive migration + seed from current `TIER_LIMITS` - `memento-note/lib/entitlements.ts` — load limits from DB with in-memory cache (TTL ~60s) and hardcoded fallback; export `getLimit()` async; keep fail-open on Redis errors - `memento-note/lib/billing/stripe-prices.ts` — read price IDs via `getConfigValue()` with env fallback; remove sole dependence on `process.env` - `memento-note/lib/config.ts` — add billing keys to `ENV_FALLBACKS` (`BILLING_ENABLED`, four `STRIPE_PRICE_*`) - `memento-note/app/actions/admin-billing.ts` — server actions: `getPlanEntitlements`, `updatePlanEntitlement`, `updateBillingConfig`, `getUsageOverview` - `memento-note/app/(admin)/admin/billing/page.tsx` — admin UI: tier limits matrix, billing config, usage summary table - `memento-note/app/(admin)/admin/billing/billing-admin-form.tsx` — client form component - `memento-note/components/admin-sidebar.tsx` — nav link `/admin/billing` - `memento-note/app/actions/admin.ts` — log `SUBSCRIPTION_OVERRIDE` audit on `updateUserSubscription` - `memento-note/lib/audit-log.ts` — extend `AuditAction` with `SUBSCRIPTION_OVERRIDE`, `BILLING_CONFIG_UPDATED`, `PLAN_ENTITLEMENT_UPDATED` - `memento-note/components/settings/billing-plans.tsx` — read `billingEnabled` from `/api/billing/status` instead of build-time env only - `memento-note/app/api/billing/status/route.ts` — include `billingEnabled` from SystemConfig - `memento-note/app/api/ai/suggest-charts/route.ts` — fix usage tracking (use `incrementUsageAsync`, not broken `trackFeatureUsage` call) - AI routes using `checkEntitlementOrThrow` + `incrementUsageAsync` — migrate to `reserveUsageOrThrow` only (remove duplicate increment on success); include chat route ## Tasks & Acceptance **Execution:** - [x] `prisma/schema.prisma` + migration — add `PlanEntitlement`, seed defaults from `TIER_LIMITS` — DB-backed limits without breaking existing users - [x] `lib/entitlements.ts` — async limit loader with cache + fallback; update `canUseFeature`, `reserveUsageOrThrow`, `getUserQuotas` — single source of truth - [x] `lib/billing/stripe-prices.ts` + `lib/config.ts` — SystemConfig-backed price IDs and `BILLING_ENABLED` — admin-editable business config - [x] `app/actions/admin-billing.ts` — CRUD entitlements + billing config + usage overview query (join `UsageLog` + Redis snapshot) — secure admin API surface - [x] `app/(admin)/admin/billing/*` + `components/admin-sidebar.tsx` — admin UI section — operator-facing management - [x] `app/actions/admin.ts` + `lib/audit-log.ts` — audit trail for tier overrides and config changes — support accountability - [x] AI routes (grep `checkEntitlementOrThrow`) + `app/api/chat/route.ts` — switch to `reserveUsageOrThrow`; drop redundant `incrementUsageAsync` on success — fix TOCTOU race - [x] `app/api/ai/suggest-charts/route.ts` — fix broken usage call — restore quota accounting - [x] `app/api/billing/status/route.ts` + `components/settings/billing-plans.tsx` — runtime billing flag — no redeploy to toggle billing UI - [x] `locales/en.json` + `locales/fr.json` — i18n keys under `admin.billing.*` — FR/EN reference labels **Acceptance Criteria:** - Given an ADMIN on `/admin/billing`, when they change PRO `chat` limit and save, then a new entitlement check within 60s reflects the new limit without app restart. - Given a BASIC user at quota, when two parallel AI requests arrive, then at most one succeeds and the other returns HTTP 402. - Given `BILLING_ENABLED` is false in SystemConfig, when a user opens `/settings/billing`, then checkout/upgrade actions are hidden and a clear disabled message is shown. - Given an ADMIN changes a user's tier from `/admin/users`, when the change succeeds, then an `AuditLog` row records actor, target user, old tier, and new tier. - Given Stripe price IDs are set in SystemConfig (env unset), when checkout is initiated, then the correct Stripe price is used. - Given Redis is unreachable, when an AI request is made, then the request is allowed (fail-open) and an error is logged server-side. - Given `STRIPE_SECRET_KEY` remains env-only, when inspecting `SystemConfig` table, then no `sk_` or `whsec_` values exist. ## Design Notes **PlanEntitlement limit encoding:** `limitValue: null` → unlimited; positive integer → monthly request cap; omit row (or explicit `-1` sentinel if needed) → feature not available on tier (maps to current `undefined` in `TIER_LIMITS`). **Cache invalidation:** After admin saves entitlements, call a small `invalidateEntitlementCache()` so changes apply immediately without waiting for TTL. **Quota migration pattern:** Replace `await checkEntitlementOrThrow(userId, feature)` + `incrementUsageAsync(...)` with `await reserveUsageOrThrow(userId, feature)` at request start. Do not increment again on success. For streaming chat, reserve before `streamText`; no post-finish increment. **Usage dashboard:** Show per-tier aggregate from `UsageLog` (last synced month) plus optional live Redis read for current month top features — degraded state if sync cron stale is acceptable (show `syncedAt`). ## Verification **Commands:** - `cd memento-note && npm run test:unit -- tests/unit/entitlements.test.ts` — expected: pass after async limit loader changes - `cd memento-note && npx tsc --noEmit` — expected: no type errors **Manual checks:** - ADMIN `/admin/billing`: edit a limit, save, verify `/api/usage/current` reflects new cap for a test user on that tier. - Non-admin GET to admin-billing server actions returns unauthorized. - Two rapid AI calls at quota−1: only one succeeds. ## Spec Change Log ## Suggested Review Order **Entitlements & DB limits** - Central loader: DB `PlanEntitlement` with 60s cache and hardcoded fallback [`plan-entitlements.ts:1`](../../memento-note/lib/plan-entitlements.ts#L1) - Prisma model + seeded migration from former `TIER_LIMITS` [`schema.prisma:814`](../../memento-note/prisma/schema.prisma#L814) - Entitlements API now reads limits asynchronously from cache [`entitlements.ts:1`](../../memento-note/lib/entitlements.ts#L1) **Admin console** - Server actions: entitlements CRUD, billing config, usage overview [`admin-billing.ts:1`](../../memento-note/app/actions/admin-billing.ts#L1) - Admin UI at `/admin/billing` with tier matrix + Stripe price IDs [`billing-admin-client.tsx:1`](../../memento-note/app/(admin)/admin/billing/billing-admin-client.tsx#L1) - Audit trail on manual tier override [`admin.ts:142`](../../memento-note/app/actions/admin.ts#L142) **Stripe & billing UX** - Price IDs and `BILLING_ENABLED` via SystemConfig (env fallback) [`stripe-prices.ts:79`](../../memento-note/lib/billing/stripe-prices.ts#L79) - Runtime billing flag exposed to settings page [`billing-plans.tsx:80`](../../memento-note/components/settings/billing-plans.tsx#L80) **Quota hardening** - Atomic `reserveUsageOrThrow` on billable AI routes (replaces check+increment) [`chat/route.ts:69`](../../memento-note/app/api/chat/route.ts#L69) - Fixed broken suggest-charts usage tracking [`suggest-charts/route.ts:58`](../../memento-note/app/api/ai/suggest-charts/route.ts#L58) **Tests** - Entitlements + billing price map unit tests updated [`entitlements.test.ts:1`](../../memento-note/tests/unit/entitlements.test.ts#L1)