diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json index 3d6e898..86ab88d 100644 --- a/.cursor/hooks/state/continual-learning.json +++ b/.cursor/hooks/state/continual-learning.json @@ -1,8 +1,8 @@ { "version": 1, "lastRunAtMs": 1779998560332, - "turnsSinceLastRun": 4, + "turnsSinceLastRun": 9, "lastTranscriptMtimeMs": 1779998515529, - "lastProcessedGenerationId": "f664ecb0-412b-4c4e-b3ba-15f553a6b686", + "lastProcessedGenerationId": "3fb0d1db-af2c-46f8-b80f-c068ff52d0b9", "trialStartedAtMs": null } diff --git a/_bmad-output/implementation-artifacts/spec-subscription-billing-admin-management.md b/_bmad-output/implementation-artifacts/spec-subscription-billing-admin-management.md new file mode 100644 index 0000000..2891b54 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-subscription-billing-admin-management.md @@ -0,0 +1,163 @@ +--- +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) diff --git a/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx b/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx new file mode 100644 index 0000000..0d6d46f --- /dev/null +++ b/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx @@ -0,0 +1,303 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing' +import { toast } from 'sonner' +import { useLanguage } from '@/lib/i18n' +import { CreditCard, Gauge, Settings2 } from 'lucide-react' + +type EntitlementRow = { + tier: string + feature: string + limitValue: number | null + mode: 'limited' | 'unlimited' | 'unavailable' +} + +type BillingAdminData = { + entitlements: EntitlementRow[] + billingConfig: Record + usageOverview: { + period: string + lastSyncedAt: string | null + byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }> + topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }> + } + features: string[] + tiers: string[] +} + +function getEntitlement( + entitlements: EntitlementRow[], + tier: string, + feature: string, +): EntitlementRow | undefined { + return entitlements.find((e) => e.tier === tier && e.feature === feature) +} + +export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) { + const { t } = useLanguage() + const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC') + const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true') + const [isSavingBilling, setIsSavingBilling] = useState(false) + const [savingCell, setSavingCell] = useState(null) + + const handleSaveBilling = async (formData: FormData) => { + setIsSavingBilling(true) + try { + const data: Record = { + BILLING_ENABLED: billingEnabled ? 'true' : 'false', + STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''), + STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''), + STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''), + STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''), + } + await updateBillingConfig(data) + toast.success(t('admin.billing.configSaved')) + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : t('admin.billing.configFailed')) + } finally { + setIsSavingBilling(false) + } + } + + const handleEntitlementChange = async ( + feature: string, + mode: 'unavailable' | 'unlimited' | 'limited', + limitValue?: number, + ) => { + const key = `${activeTier}:${feature}` + setSavingCell(key) + try { + await updatePlanEntitlement(activeTier, feature, mode, limitValue) + toast.success(t('admin.billing.limitSaved')) + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed')) + } finally { + setSavingCell(null) + } + } + + return ( +
+
+

{t('admin.billing.title')}

+

{t('admin.billing.description')}

+
+ +
+
+
+ +
+
+

{t('admin.billing.stripeConfigTitle')}

+

{t('admin.billing.stripeConfigDescription')}

+
+
+
{ e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4"> +
+ setBillingEnabled(!!c)} + /> + +
+
+ {(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => ( +
+ + +
+ ))} +
+

{t('admin.billing.secretsNote')}

+ +
+
+ +
+
+
+ +
+
+

{t('admin.billing.limitsTitle')}

+

{t('admin.billing.limitsDescription')}

+
+
+
+
+ {initialData.tiers.map((tier) => ( + + ))} +
+
+ + + + + + + + + + + {initialData.features.map((feature) => { + const row = getEntitlement(initialData.entitlements, activeTier, feature) + const mode = row?.mode ?? 'unavailable' + const cellKey = `${activeTier}:${feature}` + return ( + + ) + })} + +
{t('admin.billing.feature')}{t('admin.billing.mode')}{t('admin.billing.monthlyLimit')}{t('admin.billing.actions')}
+
+
+
+ +
+
+
+ +
+
+

{t('admin.billing.usageTitle')}

+

+ {t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })} + {initialData.usageOverview.lastSyncedAt + ? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}` + : ` · ${t('admin.billing.notSynced')}`} +

+
+
+
+
+

{t('admin.billing.byFeature')}

+ {initialData.usageOverview.byFeature.length === 0 ? ( +

{t('admin.billing.noUsageData')}

+ ) : ( +
    + {initialData.usageOverview.byFeature.map((row) => ( +
  • + {row.feature} + {row.requests} req · {row.tokens} tok +
  • + ))} +
+ )} +
+
+

{t('admin.billing.topUsers')}

+ {initialData.usageOverview.topUsers.length === 0 ? ( +

{t('admin.billing.noUsageData')}

+ ) : ( +
    + {initialData.usageOverview.topUsers.map((row) => ( +
  • + {row.email} + {row.requests} req +
  • + ))} +
+ )} +
+
+
+
+ ) +} + +function EntitlementRowEditor({ + feature, + mode: initialMode, + limitValue: initialLimit, + isSaving, + onSave, + t, +}: { + feature: string + mode: 'unavailable' | 'unlimited' | 'limited' + limitValue: number | null + isSaving: boolean + onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise + t: (key: string, params?: Record) => string +}) { + const [mode, setMode] = useState(initialMode) + const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50') + + useEffect(() => { + setMode(initialMode) + setLimit(initialLimit != null ? String(initialLimit) : '50') + }, [initialMode, initialLimit]) + + return ( + + {feature} + + + + + {mode === 'limited' ? ( + setLimit(e.target.value)} + className="h-9 w-24 text-xs" + /> + ) : ( + + )} + + + + + + ) +} diff --git a/memento-note/app/(admin)/admin/billing/page.tsx b/memento-note/app/(admin)/admin/billing/page.tsx new file mode 100644 index 0000000..16b321d --- /dev/null +++ b/memento-note/app/(admin)/admin/billing/page.tsx @@ -0,0 +1,9 @@ +import { getBillingAdminData } from '@/app/actions/admin-billing' +import { BillingAdminClient } from './billing-admin-client' + +export const dynamic = 'force-dynamic' + +export default async function AdminBillingPage() { + const data = await getBillingAdminData() + return +} diff --git a/memento-note/app/actions/admin-billing.ts b/memento-note/app/actions/admin-billing.ts new file mode 100644 index 0000000..f8de3c0 --- /dev/null +++ b/memento-note/app/actions/admin-billing.ts @@ -0,0 +1,215 @@ +'use server' + +import prisma from '@/lib/prisma' +import { auth } from '@/auth' +import { SubscriptionTier } from '@prisma/client' +import { VALID_FEATURES, getCurrentPeriodKey } from '@/lib/quota-utils' +import { + getAllEntitlementsForAdmin, + invalidateEntitlementCache, + type SubscriptionTier as TierType, +} from '@/lib/plan-entitlements' +import { logAuditEventAsync } from '@/lib/audit-log' +import { revalidatePath } from 'next/cache' + +const BILLING_CONFIG_KEYS = [ + 'BILLING_ENABLED', + 'STRIPE_PRICE_PRO_MONTHLY', + 'STRIPE_PRICE_PRO_ANNUAL', + 'STRIPE_PRICE_BUSINESS_MONTHLY', + 'STRIPE_PRICE_BUSINESS_ANNUAL', +] as const + +const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] + +async function checkAdmin() { + const session = await auth() + if (!session?.user?.id || (session.user as { role?: string }).role !== 'ADMIN') { + throw new Error('Unauthorized: Admin access required') + } + return session +} + +function assertValidFeature(feature: string) { + if (!(VALID_FEATURES as readonly string[]).includes(feature)) { + throw new Error(`Invalid feature: ${feature}`) + } +} + +function assertValidTier(tier: string): asserts tier is TierType { + if (!TIERS.includes(tier as TierType)) { + throw new Error(`Invalid tier: ${tier}`) + } +} + +export async function getBillingAdminData() { + await checkAdmin() + const { getSystemConfig } = await import('@/lib/config') + const config = await getSystemConfig() + const entitlements = await getAllEntitlementsForAdmin() + const usageOverview = await getUsageOverviewInternal() + + const billingConfig = Object.fromEntries( + BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']), + ) + + return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS } +} + +export async function updatePlanEntitlement( + tier: string, + feature: string, + mode: 'unavailable' | 'unlimited' | 'limited', + limitValue?: number, +) { + const session = await checkAdmin() + assertValidTier(tier) + assertValidFeature(feature) + + if (mode === 'limited') { + if (limitValue === undefined || !Number.isFinite(limitValue) || limitValue < 0) { + throw new Error('Limit must be a non-negative number') + } + } + + if (mode === 'unavailable') { + await prisma.planEntitlement.deleteMany({ + where: { tier: tier as SubscriptionTier, feature }, + }) + } else { + await prisma.planEntitlement.upsert({ + where: { + tier_feature: { + tier: tier as SubscriptionTier, + feature, + }, + }, + update: { + limitValue: mode === 'unlimited' ? null : Math.round(limitValue!), + }, + create: { + tier: tier as SubscriptionTier, + feature, + limitValue: mode === 'unlimited' ? null : Math.round(limitValue!), + }, + }) + } + + invalidateEntitlementCache() + + await logAuditEventAsync({ + userId: session.user?.id, + action: 'PLAN_ENTITLEMENT_UPDATED', + resource: `${tier}:${feature}`, + metadata: { tier, feature, mode, limitValue: mode === 'limited' ? limitValue : mode }, + }) + + revalidatePath('/admin/billing') + return { success: true } +} + +export async function updateBillingConfig(data: Record) { + const session = await checkAdmin() + + const filtered = Object.fromEntries( + Object.entries(data).filter(([key, value]) => + (BILLING_CONFIG_KEYS as readonly string[]).includes(key) + && value !== '' + && !value.includes('sk_') + && !value.includes('whsec_'), + ), + ) + + if (filtered.BILLING_ENABLED === 'true') { + const required = [ + 'STRIPE_PRICE_PRO_MONTHLY', + 'STRIPE_PRICE_PRO_ANNUAL', + 'STRIPE_PRICE_BUSINESS_MONTHLY', + 'STRIPE_PRICE_BUSINESS_ANNUAL', + ] as const + for (const key of required) { + if (!filtered[key] && !process.env[key]) { + throw new Error(`Missing ${key} when billing is enabled`) + } + } + } + + const operations = Object.entries(filtered).map(([key, value]) => + prisma.systemConfig.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }), + ) + + await prisma.$transaction(operations) + + await logAuditEventAsync({ + userId: session.user?.id, + action: 'BILLING_CONFIG_UPDATED', + resource: 'billing', + metadata: { keys: Object.keys(filtered) }, + }) + + revalidatePath('/admin/billing') + revalidatePath('/settings/billing') + return { success: true } +} + +async function getUsageOverviewInternal() { + const period = getCurrentPeriodKey() + const periodStart = new Date(`${period}-01`) + + const aggregated = await prisma.usageLog.groupBy({ + by: ['feature'], + where: { periodStart }, + _sum: { requestsCount: true, tokensUsed: true }, + _count: { userId: true }, + }) + + const lastSync = await prisma.usageLog.findFirst({ + where: { periodStart }, + orderBy: { syncedAt: 'desc' }, + select: { syncedAt: true }, + }) + + const topUsers = await prisma.usageLog.groupBy({ + by: ['userId'], + where: { periodStart }, + _sum: { requestsCount: true }, + orderBy: { _sum: { requestsCount: 'desc' } }, + take: 10, + }) + + const userIds = topUsers.map((u) => u.userId) + const users = userIds.length + ? await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, email: true, name: true }, + }) + : [] + + const userMap = Object.fromEntries(users.map((u) => [u.id, u])) + + return { + period, + lastSyncedAt: lastSync?.syncedAt?.toISOString() ?? null, + byFeature: aggregated.map((row) => ({ + feature: row.feature, + requests: row._sum.requestsCount ?? 0, + tokens: row._sum.tokensUsed ?? 0, + users: row._count.userId, + })), + topUsers: topUsers.map((row) => ({ + userId: row.userId, + email: userMap[row.userId]?.email ?? row.userId, + name: userMap[row.userId]?.name ?? null, + requests: row._sum.requestsCount ?? 0, + })), + } +} + +export async function getUsageOverview() { + await checkAdmin() + return getUsageOverviewInternal() +} diff --git a/memento-note/app/actions/admin.ts b/memento-note/app/actions/admin.ts index ea1a170..0c3eca8 100644 --- a/memento-note/app/actions/admin.ts +++ b/memento-note/app/actions/admin.ts @@ -140,7 +140,7 @@ export async function updateUserRole(userId: string, newRole: string) { } export async function updateUserSubscription(userId: string, tier: string) { - await checkAdmin() + const session = await checkAdmin() const validTiers: string[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] if (!validTiers.includes(tier)) { @@ -148,6 +148,9 @@ export async function updateUserSubscription(userId: string, tier: string) { } try { + const existing = await prisma.subscription.findUnique({ where: { userId } }) + const oldTier = existing?.tier ?? 'BASIC' + const now = new Date() const periodEnd = new Date(now) periodEnd.setFullYear(periodEnd.getFullYear() + 1) @@ -168,6 +171,15 @@ export async function updateUserSubscription(userId: string, tier: string) { currentPeriodEnd: periodEnd, }, }) + + const { logAuditEventAsync } = await import('@/lib/audit-log') + await logAuditEventAsync({ + userId: session.user?.id, + action: 'SUBSCRIPTION_OVERRIDE', + resource: userId, + metadata: { oldTier, newTier: tier, targetUserId: userId }, + }) + revalidatePath('/admin') return { success: true } } catch (error) { diff --git a/memento-note/app/actions/diagram.ts b/memento-note/app/actions/diagram.ts index 7f13b56..2ec899b 100644 --- a/memento-note/app/actions/diagram.ts +++ b/memento-note/app/actions/diagram.ts @@ -4,7 +4,7 @@ import { auth } from '@/auth' import prisma from '@/lib/prisma' import { getSystemConfig } from '@/lib/config' import { getChatProvider } from '@/lib/ai/factory' -import { checkEntitlementOrThrow, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow } from '@/lib/entitlements' import { toolRegistry } from '@/lib/ai/tools/registry' // S'assurer que l'outil est importé pour s'enregistrer dans le registre @@ -72,7 +72,7 @@ export async function generateDiagramFromText(text: string): Promise<{ success: try { // 1. Vérification et déduction des quotas - await checkEntitlementOrThrow(userId, 'excalidraw_generate') + await reserveUsageOrThrow(userId, 'excalidraw_generate') // 2. Instancier le modèle de chat IA const systemConfig = await getSystemConfig() @@ -106,9 +106,6 @@ export async function generateDiagramFromText(text: string): Promise<{ success: return { success: false, error: result.error || "La création du canevas a échoué." } } - // 6. Incrémenter le quota - await incrementUsageAsync(userId, 'excalidraw_generate') - return { success: true, canvasId: result.canvasId } } catch (err: any) { diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index dd611e1..e3bfeb6 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -11,7 +11,7 @@ import { embeddingService } from '@/lib/ai/services/embedding.service' import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links' import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config' import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service' -import { incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow } from '@/lib/entitlements' import { semanticSearchService } from '@/lib/ai/services/semantic-search.service' import { getAISettings } from '@/app/actions/ai-settings' import { @@ -521,7 +521,7 @@ export async function createNote(data: { const merged = [...new Set([...existingNames, ...appliedLabels])] await syncNoteLabels(noteId, merged, notebookId ?? null, userId) // Incrémenter le quota une seule fois par sauvegarde où des labels IA sont appliqués - incrementUsageAsync(userId, 'auto_tag') + await reserveUsageOrThrow(userId, 'auto_tag') if (!data.skipRevalidation) { revalidatePath('/home') } diff --git a/memento-note/app/actions/semantic-search.ts b/memento-note/app/actions/semantic-search.ts index ee8292f..870ac5a 100644 --- a/memento-note/app/actions/semantic-search.ts +++ b/memento-note/app/actions/semantic-search.ts @@ -2,7 +2,7 @@ import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service' import { auth } from '@/auth' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' export interface SemanticSearchResponse { results: SearchResult[] @@ -25,12 +25,11 @@ export async function semanticSearch( const session = await auth(); if (session?.user?.id) { try { - await checkEntitlementOrThrow(session.user.id, 'semantic_search'); + await reserveUsageOrThrow(session.user.id, 'semantic_search'); } catch (err) { if (err instanceof QuotaExceededError) throw err; console.error('[semantic-search] Quota check error (fail-open):', err); } - incrementUsageAsync(session.user.id, 'semantic_search'); } try { diff --git a/memento-note/app/api/agents/run-for-note/route.ts b/memento-note/app/api/agents/run-for-note/route.ts index 274239f..5e7e9b8 100644 --- a/memento-note/app/api/agents/run-for-note/route.ts +++ b/memento-note/app/api/agents/run-for-note/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import { prisma } from '@/lib/prisma' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { logAuditEvent, getClientIp } from '@/lib/audit-log' type GenerateType = 'slide-generator' | 'excalidraw-generator' @@ -48,7 +48,7 @@ export async function POST(req: NextRequest) { // Quota check — feature key depends on generation type const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate' try { - await checkEntitlementOrThrow(userId, featureKey) + await reserveUsageOrThrow(userId, featureKey) } catch (e) { if (e instanceof QuotaExceededError) { return NextResponse.json({ error: e.message }, { status: 402 }) diff --git a/memento-note/app/api/ai/generate-exercises/route.ts b/memento-note/app/api/ai/generate-exercises/route.ts index d555a3b..b163f62 100644 --- a/memento-note/app/api/ai/generate-exercises/route.ts +++ b/memento-note/app/api/ai/generate-exercises/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { preprocessMathInHtml } from '@/lib/text/math-preprocess' export async function POST(request: NextRequest) { @@ -18,7 +18,7 @@ export async function POST(request: NextRequest) { } try { - await checkEntitlementOrThrow(session.user.id, 'reformulate') + await reserveUsageOrThrow(session.user.id, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -88,7 +88,6 @@ export async function POST(request: NextRequest) { })() } - incrementUsageAsync(session.user.id, 'reformulate') return NextResponse.json({ exercises: createdNotes.map(n => ({ id: n.id, title: n.title })), diff --git a/memento-note/app/api/ai/math-from-text/route.ts b/memento-note/app/api/ai/math-from-text/route.ts index 1b28683..8c7a379 100644 --- a/memento-note/app/api/ai/math-from-text/route.ts +++ b/memento-note/app/api/ai/math-from-text/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import { mathFromTextService } from '@/lib/ai/services/math-from-text.service' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' export async function POST(request: NextRequest) { try { @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { } try { - await checkEntitlementOrThrow(session.user.id, 'reformulate') + await reserveUsageOrThrow(session.user.id, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -29,7 +29,6 @@ export async function POST(request: NextRequest) { } const latex = await mathFromTextService.generate(description) - incrementUsageAsync(session.user.id, 'reformulate') return NextResponse.json({ latex }) } catch (error: any) { diff --git a/memento-note/app/api/ai/notebook-wizard/route.ts b/memento-note/app/api/ai/notebook-wizard/route.ts index 6648138..943a0c6 100644 --- a/memento-note/app/api/ai/notebook-wizard/route.ts +++ b/memento-note/app/api/ai/notebook-wizard/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { notebookWizardService, type WizardProfile } from '@/lib/ai/services/notebook-wizard.service' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' export async function POST(request: NextRequest) { try { @@ -18,7 +18,7 @@ export async function POST(request: NextRequest) { } try { - await checkEntitlementOrThrow(session.user.id, 'reformulate') + await reserveUsageOrThrow(session.user.id, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -120,7 +120,6 @@ export async function POST(request: NextRequest) { })() } - incrementUsageAsync(session.user.id, 'reformulate') return NextResponse.json({ notebookId: notebook.id, diff --git a/memento-note/app/api/ai/organize-notebook/route.ts b/memento-note/app/api/ai/organize-notebook/route.ts index 0663f25..b9c2e90 100644 --- a/memento-note/app/api/ai/organize-notebook/route.ts +++ b/memento-note/app/api/ai/organize-notebook/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { syncNoteLabels } from '@/app/actions/notes' export async function POST(request: NextRequest) { @@ -18,7 +18,7 @@ export async function POST(request: NextRequest) { } try { - await checkEntitlementOrThrow(session.user.id, 'reformulate') + await reserveUsageOrThrow(session.user.id, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -48,7 +48,6 @@ export async function POST(request: NextRequest) { const result = await notebookOrganizerService.analyze(notesForAnalysis) - incrementUsageAsync(session.user.id, 'reformulate') return NextResponse.json(result) } catch (error: any) { diff --git a/memento-note/app/api/ai/personas/route.ts b/memento-note/app/api/ai/personas/route.ts index 0f9154e..013b831 100644 --- a/memento-note/app/api/ai/personas/route.ts +++ b/memento-note/app/api/ai/personas/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import { getSystemConfig } from '@/lib/config' import { getTagsProvider } from '@/lib/ai/factory' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { hasUserAiConsent } from '@/lib/consent/server-consent' export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist' @@ -90,7 +90,7 @@ export async function POST(request: NextRequest) { // Quota check (reuse reformulate quota) try { - await checkEntitlementOrThrow(session.user.id, 'reformulate') + await reserveUsageOrThrow(session.user.id, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -114,7 +114,6 @@ export async function POST(request: NextRequest) { const fullPrompt = `${persona.systemPrompt}\n\n---\nNOTE À ANALYSER :\n${plainText}` const result = await provider.generateText(fullPrompt) - incrementUsageAsync(session.user.id, 'reformulate') return NextResponse.json({ personaId: persona.id, diff --git a/memento-note/app/api/ai/reformulate/route.ts b/memento-note/app/api/ai/reformulate/route.ts index 1039429..f42dc1d 100644 --- a/memento-note/app/api/ai/reformulate/route.ts +++ b/memento-note/app/api/ai/reformulate/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service' import { getAISettings } from '@/app/actions/ai-settings' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { hasUserAiConsent } from '@/lib/consent/server-consent' export async function POST(request: NextRequest) { @@ -58,7 +58,7 @@ export async function POST(request: NextRequest) { // Check quota try { - await checkEntitlementOrThrow(session.user.id, 'reformulate') + await reserveUsageOrThrow(session.user.id, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -75,8 +75,6 @@ export async function POST(request: NextRequest) { // Use the ParagraphRefactorService const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language, writePrompt) - incrementUsageAsync(session.user.id, 'reformulate') - return NextResponse.json({ originalText: result.original, reformulatedText: result.refactored, diff --git a/memento-note/app/api/ai/study-plan/route.ts b/memento-note/app/api/ai/study-plan/route.ts index 868bf9a..017d964 100644 --- a/memento-note/app/api/ai/study-plan/route.ts +++ b/memento-note/app/api/ai/study-plan/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { studyPlannerService } from '@/lib/ai/services/study-planner.service' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' export async function POST(request: NextRequest) { try { @@ -17,7 +17,7 @@ export async function POST(request: NextRequest) { } try { - await checkEntitlementOrThrow(session.user.id, 'reformulate') + await reserveUsageOrThrow(session.user.id, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -61,7 +61,6 @@ export async function POST(request: NextRequest) { } } - incrementUsageAsync(session.user.id, 'reformulate') return NextResponse.json(plan) } catch (error: any) { diff --git a/memento-note/app/api/ai/suggest-charts/route.ts b/memento-note/app/api/ai/suggest-charts/route.ts index 8e37ed0..74ee14e 100644 --- a/memento-note/app/api/ai/suggest-charts/route.ts +++ b/memento-note/app/api/ai/suggest-charts/route.ts @@ -4,8 +4,7 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user' import { getSystemConfig } from '@/lib/config' import { prisma } from '@/lib/prisma' import { auth } from '@/auth' -import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements' -import { trackFeatureUsage } from '@/lib/usage-tracker' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { hasUserAiConsent } from '@/lib/consent/server-consent' export const maxDuration = 30 @@ -56,7 +55,7 @@ export async function POST(req: Request) { const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId) console.log('[suggest-charts] BYOK:', willUseByok) if (!willUseByok) { - await checkEntitlementOrThrow(userId, 'suggest_charts') + await reserveUsageOrThrow(userId, 'suggest_charts') console.log('[suggest-charts] Quota OK') } } catch (err) { @@ -243,9 +242,6 @@ Response format (COPY this structure): parsed.hasData = false } - // Track usage - await trackFeatureUsage(userId, 'suggest_charts', 'suggest-charts', 1) - return Response.json(parsed satisfies SuggestChartsResponse) } catch (error) { diff --git a/memento-note/app/api/ai/tags/route.ts b/memento-note/app/api/ai/tags/route.ts index 70eeeb2..a2cb909 100644 --- a/memento-note/app/api/ai/tags/route.ts +++ b/memento-note/app/api/ai/tags/route.ts @@ -4,7 +4,7 @@ import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag. import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'; import { getSystemConfig } from '@/lib/config'; import { z } from 'zod'; -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'; +import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements'; import { hasUserAiConsent } from '@/lib/consent/server-consent'; import { getAISettings } from '@/app/actions/ai-settings'; diff --git a/memento-note/app/api/ai/title-suggestions/route.ts b/memento-note/app/api/ai/title-suggestions/route.ts index 6a72084..595d840 100644 --- a/memento-note/app/api/ai/title-suggestions/route.ts +++ b/memento-note/app/api/ai/title-suggestions/route.ts @@ -3,7 +3,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo import { getSystemConfig } from '@/lib/config' import { auth } from '@/auth' import { getAISettings } from '@/app/actions/ai-settings' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { z } from 'zod' import { hasUserAiConsent } from '@/lib/consent/server-consent' @@ -43,18 +43,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ suggestions: [] }) } - try { - const config = await getSystemConfig() - const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id); - if (!willUseByok) { - await checkEntitlementOrThrow(session.user.id, 'auto_title'); - } - } catch (err) { - if (err instanceof QuotaExceededError) { - return NextResponse.json(err.toJSON(), { status: 402 }); - } - console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err); - } const body = await req.json() const { content: rawContent } = requestSchema.parse(body) @@ -72,6 +60,17 @@ export async function POST(req: NextRequest) { } const config = await getSystemConfig() + const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id) + if (!willUseByok) { + try { + await reserveUsageOrThrow(session.user.id, 'auto_title') + } catch (err) { + if (err instanceof QuotaExceededError) { + return NextResponse.json(err.toJSON(), { status: 402 }) + } + console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err) + } + } // Détecter la langue du contenu (simple détection basée sur les caractères et mots) const isPersian = /[\u0600-\u06FF]/.test(content) @@ -130,13 +129,12 @@ CONTENT_START: ${content.substring(0, 3000)} CONTENT_END Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]` - const { result: titles, usedByok } = await runLaneWithBillingUser( + const { result: titles } = await runLaneWithBillingUser( 'tags', config, session.user.id, (provider) => provider.generateTitles(titlePrompt), ) - if (!usedByok) incrementUsageAsync(session.user.id, 'auto_title') // Créer les suggestions const suggestions = titles.map((t: any) => ({ diff --git a/memento-note/app/api/billing/create-checkout/route.ts b/memento-note/app/api/billing/create-checkout/route.ts index ee5d475..d4a6804 100644 --- a/memento-note/app/api/billing/create-checkout/route.ts +++ b/memento-note/app/api/billing/create-checkout/route.ts @@ -26,7 +26,7 @@ export async function POST(req: NextRequest) { const userEmail = session.user.email; try { - const priceId = resolvePriceId(tier, interval); + const priceId = await resolvePriceId(tier, interval); const subscription = await prisma.subscription.findUnique({ where: { userId } }); let customerId = subscription?.stripeCustomerId ?? undefined; diff --git a/memento-note/app/api/billing/status/route.ts b/memento-note/app/api/billing/status/route.ts index d357660..0b4a7c7 100644 --- a/memento-note/app/api/billing/status/route.ts +++ b/memento-note/app/api/billing/status/route.ts @@ -3,7 +3,7 @@ import { auth } from '@/auth'; import { getUserInfo, getEffectiveTier } from '@/lib/entitlements'; import { stripe } from '@/lib/stripe'; import type Stripe from 'stripe'; -import { priceIdToTier, getDynamicPrices } from '@/lib/billing/stripe-prices'; +import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices'; export const dynamic = 'force-dynamic'; @@ -28,7 +28,7 @@ export async function GET(req: NextRequest) { const sub = await stripe.subscriptions.retrieve(subId) as any; const priceId = sub.items.data[0].price.id; - const tier = priceIdToTier(priceId) || (checkoutSession.metadata?.tier as any) || 'PRO'; + const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO'; const currentPeriodStartTimestamp = sub.current_period_start ?? @@ -75,6 +75,7 @@ export async function GET(req: NextRequest) { const effectiveTier = await getEffectiveTier(userId); const subscription = await prisma.subscription.findUnique({ where: { userId } }); const prices = await getDynamicPrices(); + const billingEnabled = await isBillingEnabled(); return NextResponse.json({ tier, @@ -85,6 +86,7 @@ export async function GET(req: NextRequest) { cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false, hasStripeSubscription: !!subscription?.stripeSubscriptionId, prices, + billingEnabled, }); } catch (error) { console.error('[billing/status]', error); diff --git a/memento-note/app/api/brainstorm/route.ts b/memento-note/app/api/brainstorm/route.ts index f19d20c..3a8af59 100644 --- a/memento-note/app/api/brainstorm/route.ts +++ b/memento-note/app/api/brainstorm/route.ts @@ -6,9 +6,8 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo import { getSystemConfig } from '@/lib/config' import { embeddingService } from '@/lib/ai/services/embedding.service' import { - checkEntitlementOrThrow, + reserveUsageOrThrow, QuotaExceededError, - incrementUsageAsync, } from '@/lib/entitlements' import { logActivity, captureSnapshot } from '@/lib/brainstorm-collab' @@ -221,7 +220,7 @@ export async function POST(request: NextRequest) { const config = await getSystemConfig() const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId) if (!willUseByok) { - await checkEntitlementOrThrow(userId, 'brainstorm_create') + await reserveUsageOrThrow(userId, 'brainstorm_create') } const classifiedNotes = await autoContextSearch(userId, seedIdea, contextNoteIds) @@ -233,7 +232,6 @@ export async function POST(request: NextRequest) { userId, (provider) => provider.generateText(prompt), ) - if (!usedByok) incrementUsageAsync(userId, 'brainstorm_create') let ideas: any[] try { diff --git a/memento-note/app/api/chat/route.ts b/memento-note/app/api/chat/route.ts index 8140a71..cc055f2 100644 --- a/memento-note/app/api/chat/route.ts +++ b/memento-note/app/api/chat/route.ts @@ -9,7 +9,7 @@ import { auth } from '@/auth' import { hasUserAiConsent } from '@/lib/consent/server-consent' import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n' import { toolRegistry } from '@/lib/ai/tools' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { ByokUnavailableError } from '@/lib/byok' import { trackFeatureUsage } from '@/lib/usage-tracker' import { readFile } from 'fs/promises' @@ -66,7 +66,7 @@ export async function POST(req: Request) { const sysConfigEarly = await getSystemConfig() const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId) if (!willUseByok) { - await checkEntitlementOrThrow(userId, 'chat') + await reserveUsageOrThrow(userId, 'chat') } } catch (err) { if (err instanceof QuotaExceededError) { @@ -483,7 +483,6 @@ Focus ONLY on this note unless asked otherwise.` }) if (!usedByok) { trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0) - incrementUsageAsync(userId, 'chat') } logAuditEvent({ userId, diff --git a/memento-note/app/api/flashcards/generate/route.ts b/memento-note/app/api/flashcards/generate/route.ts index f25a9b7..c9a612a 100644 --- a/memento-note/app/api/flashcards/generate/route.ts +++ b/memento-note/app/api/flashcards/generate/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { getAISettings } from '@/app/actions/ai-settings' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { hasUserAiConsent } from '@/lib/consent/server-consent' import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards' import { stripHtmlToText } from '@/lib/flashcards/deck-utils' @@ -49,7 +49,7 @@ export async function POST(request: NextRequest) { } try { - await checkEntitlementOrThrow(session.user.id, 'ai_flashcard') + await reserveUsageOrThrow(session.user.id, 'ai_flashcard') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 @@ -70,7 +70,6 @@ export async function POST(request: NextRequest) { language: note.language || undefined, }) - incrementUsageAsync(session.user.id, 'ai_flashcard') return NextResponse.json({ cards, noteId: note.id, style }) } catch (error) { diff --git a/memento-note/app/api/mobile/ai/improve/route.ts b/memento-note/app/api/mobile/ai/improve/route.ts index 5f4c5bb..a010850 100644 --- a/memento-note/app/api/mobile/ai/improve/route.ts +++ b/memento-note/app/api/mobile/ai/improve/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getMobileUserId } from '@/lib/mobile-auth' import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' const MODE_MAP: Record = { improve: 'improveStyle', @@ -26,7 +26,7 @@ export async function POST(req: NextRequest) { if (!validation.valid) return NextResponse.json({ error: validation.error }, { status: 400 }) try { - await checkEntitlementOrThrow(userId, 'reformulate') + await reserveUsageOrThrow(userId, 'reformulate') } catch (err) { if (err instanceof QuotaExceededError) { return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 }) @@ -35,7 +35,6 @@ export async function POST(req: NextRequest) { } const result = await paragraphRefactorService.refactor(text, refactorMode, 'markdown', undefined) - incrementUsageAsync(userId, 'reformulate') return NextResponse.json({ improved: result.refactored, original: result.original }) } diff --git a/memento-note/app/api/mobile/ai/title/route.ts b/memento-note/app/api/mobile/ai/title/route.ts index 933c7f3..9cee754 100644 --- a/memento-note/app/api/mobile/ai/title/route.ts +++ b/memento-note/app/api/mobile/ai/title/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { getMobileUserId } from '@/lib/mobile-auth' -import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user' +import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user' import { getSystemConfig } from '@/lib/config' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' export async function POST(req: NextRequest) { const userId = getMobileUserId(req) @@ -14,25 +14,27 @@ export async function POST(req: NextRequest) { const wordCount = content.split(/\s+/).length if (wordCount < 5) return NextResponse.json({ error: 'Contenu trop court (min 5 mots)' }, { status: 400 }) - try { - await checkEntitlementOrThrow(userId, 'auto_title') - } catch (err) { - if (err instanceof QuotaExceededError) { - return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 }) + const config = await getSystemConfig() + const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId) + if (!willUseByok) { + try { + await reserveUsageOrThrow(userId, 'auto_title') + } catch (err) { + if (err instanceof QuotaExceededError) { + return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 }) + } + throw err } - throw err } - const config = await getSystemConfig() const prompt = `Génère 3 titres concis pour ce texte. Réponds UNIQUEMENT avec un tableau JSON: [{"title":"titre1"},{"title":"titre2"},{"title":"titre3"}]\n\nTexte: ${content.slice(0, 400)}` - const { result: titles, usedByok } = await runLaneWithBillingUser( + const { result: titles } = await runLaneWithBillingUser( 'tags', config, userId, (provider) => provider.generateTitles(prompt), ) - if (!usedByok) incrementUsageAsync(userId, 'auto_title') return NextResponse.json({ suggestions: (titles ?? []).map((t: any) => t.title ?? t) }) } diff --git a/memento-note/app/api/mobile/flashcards/generate/route.ts b/memento-note/app/api/mobile/flashcards/generate/route.ts index 374b567..dfced77 100644 --- a/memento-note/app/api/mobile/flashcards/generate/route.ts +++ b/memento-note/app/api/mobile/flashcards/generate/route.ts @@ -3,7 +3,7 @@ import prisma from '@/lib/prisma' import { getMobileUserId } from '@/lib/mobile-auth' import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards' import { stripHtmlToText } from '@/lib/flashcards/deck-utils' -import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' export async function POST(req: NextRequest) { const userId = getMobileUserId(req) @@ -29,7 +29,7 @@ export async function POST(req: NextRequest) { } try { - await checkEntitlementOrThrow(userId, 'ai_flashcard') + await reserveUsageOrThrow(userId, 'ai_flashcard') } catch (err) { if (err instanceof QuotaExceededError) { return NextResponse.json({ error: err.currentQuota === 0 ? 'Fonctionnalité non disponible sur votre abonnement' : 'Quota IA atteint' }, { status: 402 }) @@ -78,7 +78,6 @@ export async function POST(req: NextRequest) { }) await prisma.flashcardDeck.update({ where: { id: deckId }, data: { updatedAt: new Date() } }) - incrementUsageAsync(userId, 'ai_flashcard') return NextResponse.json({ deckId, count: cards.length, cards }) } diff --git a/memento-note/components/admin-sidebar.tsx b/memento-note/components/admin-sidebar.tsx index a489af9..c3b2cfe 100644 --- a/memento-note/components/admin-sidebar.tsx +++ b/memento-note/components/admin-sidebar.tsx @@ -13,6 +13,7 @@ import { User, LogOut, Brain, + CreditCard, } from 'lucide-react' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n' @@ -42,6 +43,11 @@ const ADMIN_NAV_ITEMS = [ href: '/admin/ai', icon: Brain, }, + { + titleKey: 'admin.sidebar.billing', + href: '/admin/billing', + icon: CreditCard, + }, { titleKey: 'admin.sidebar.published', href: '/admin/published', diff --git a/memento-note/components/editor-find-replace-bar.tsx b/memento-note/components/editor-find-replace-bar.tsx index 6eed44b..015d4e6 100644 --- a/memento-note/components/editor-find-replace-bar.tsx +++ b/memento-note/components/editor-find-replace-bar.tsx @@ -172,9 +172,14 @@ export function FindReplaceBar({ editor, onClose }: { editor: Editor; onClose: ( const replaceAll = useCallback(() => { const ms = matchesRef.current if (ms.length === 0) return + // Sort descending by position so replacements don't shift earlier positions const sorted = [...ms].sort((a, b) => b.from - a.from) - editor.chain().focus() - sorted.forEach(m => editor.chain().insertContentAt({ from: m.from, to: m.to }, replaceText).run()) + // Use a single ProseMirror transaction for all replacements + const tr = editor.state.tr + for (const m of sorted) { + tr.insertText(replaceText, m.from, m.to) + } + editor.view.dispatch(tr) matchesRef.current = [] setCount(0) setCurrentIndex(-1) diff --git a/memento-note/components/note-editor/note-editor-toolbar.tsx b/memento-note/components/note-editor/note-editor-toolbar.tsx index 334a7b3..3abf4ee 100644 --- a/memento-note/components/note-editor/note-editor-toolbar.tsx +++ b/memento-note/components/note-editor/note-editor-toolbar.tsx @@ -260,7 +260,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme }) // Emit events so the note list refreshes for (const ex of data.exercises || []) { - emitNoteChange({ type: 'created', noteId: ex.id, notebookId: note.notebookId }) + emitNoteChange({ type: 'created', note: { ...note, id: ex.id, title: ex.title, content: '

' } as any }) } } } catch (e: any) { diff --git a/memento-note/components/settings/billing-plans.tsx b/memento-note/components/settings/billing-plans.tsx index 34ce68e..1d46c22 100644 --- a/memento-note/components/settings/billing-plans.tsx +++ b/memento-note/components/settings/billing-plans.tsx @@ -22,6 +22,7 @@ interface BillingStatus { currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean; hasStripeSubscription: boolean; + billingEnabled?: boolean; prices?: { PRO: { month: { display: string; amount: number; currency: string }; @@ -34,11 +35,11 @@ interface BillingStatus { }; } -const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development'; +const billingEnabledEnvFallback = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development'; let stripePromise: ReturnType | null = null; -function getStripePromise() { - if (!billingEnabled) return null; +function getStripePromise(enabled: boolean) { + if (!enabled) return null; if (!stripePromise && process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); } @@ -77,6 +78,8 @@ export function BillingPlans() { }); const quotas = usageData?.quotas; + const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback; + const stripe = getStripePromise(billingEnabled); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -536,7 +539,7 @@ export function BillingPlans() { - {billingEnabled && ( + {billingEnabled ? (
+ ) : ( +

+ {t('billing.disabledByAdmin')} +

)}
diff --git a/memento-note/components/tiptap-callout-extension.tsx b/memento-note/components/tiptap-callout-extension.tsx index 2407d78..056ee35 100644 --- a/memento-note/components/tiptap-callout-extension.tsx +++ b/memento-note/components/tiptap-callout-extension.tsx @@ -166,7 +166,7 @@ export const CalloutExtension = Node.create({ addKeyboardShortcuts() { return { - 'Mod-Shift-C': () => this.editor.commands.insertContent({ + 'Mod-Alt-C': () => this.editor.commands.insertContent({ type: this.name, attrs: { type: 'info' }, content: [{ type: 'paragraph' }], diff --git a/memento-note/components/tiptap-columns-extension.tsx b/memento-note/components/tiptap-columns-extension.tsx index 6901225..4730556 100644 --- a/memento-note/components/tiptap-columns-extension.tsx +++ b/memento-note/components/tiptap-columns-extension.tsx @@ -86,7 +86,7 @@ export const ColumnsExtension = Node.create({ addKeyboardShortcuts() { return { - 'Mod-Shift-L': () => this.editor.commands.insertContent({ + 'Mod-Alt-L': () => this.editor.commands.insertContent({ type: this.name, attrs: { cols: 2 }, content: [ diff --git a/memento-note/components/tiptap-link-preview-extension.tsx b/memento-note/components/tiptap-link-preview-extension.tsx index 82de9a8..e66f93a 100644 --- a/memento-note/components/tiptap-link-preview-extension.tsx +++ b/memento-note/components/tiptap-link-preview-extension.tsx @@ -43,7 +43,7 @@ const LinkPreviewView = ({ node, updateAttributes, deleteNode, selected }: any) }, [url, cached, updateAttributes]) const unwrap = () => { - updateAttributes({ url: '', preview: null }) + deleteNode() } const domain = (() => { diff --git a/memento-note/components/tiptap-outline-extension.tsx b/memento-note/components/tiptap-outline-extension.tsx index fc73552..8afb00e 100644 --- a/memento-note/components/tiptap-outline-extension.tsx +++ b/memento-note/components/tiptap-outline-extension.tsx @@ -137,7 +137,7 @@ export const OutlineExtension = Node.create({ addKeyboardShortcuts() { return { - 'Mod-Shift-O': () => this.editor.commands.insertContent({ + 'Mod-Alt-O': () => this.editor.commands.insertContent({ type: this.name, }), } diff --git a/memento-note/docs/admin-billing-quotas-guide.md b/memento-note/docs/admin-billing-quotas-guide.md new file mode 100644 index 0000000..d1d6c6f --- /dev/null +++ b/memento-note/docs/admin-billing-quotas-guide.md @@ -0,0 +1,161 @@ +# Guide d'utilisation — Admin Facturation & Quotas + +## Objectif + +La page **Admin > Facturation & Quotas** permet de gérer, sans redéploiement : + +- les limites mensuelles par fonctionnalité IA (par tier) +- les Price IDs Stripe utilisés par le checkout +- l'activation/désactivation de l'interface de facturation +- un aperçu d'usage (requêtes/tokens) + +--- + +## Accès + +1. Connectez-vous avec un compte **ADMIN**. +2. Ouvrez la console admin (`/admin`). +3. Cliquez sur **Facturation & quotas** dans la sidebar. + +> Si vous n'êtes pas admin, les actions sont refusées côté serveur. + +--- + +## Section 1 — Config Stripe (métier) + +Cette section sert à configurer les éléments business de Stripe : + +- `STRIPE_PRICE_PRO_MONTHLY` +- `STRIPE_PRICE_PRO_ANNUAL` +- `STRIPE_PRICE_BUSINESS_MONTHLY` +- `STRIPE_PRICE_BUSINESS_ANNUAL` +- toggle **Activer la facturation** + +### Procédure + +1. Renseignez/mettez à jour les `price_...` IDs. +2. Activez ou désactivez la facturation via la case. +3. Cliquez sur **Enregistrer la config**. + +### Important sécurité + +- `STRIPE_SECRET_KEY` et `STRIPE_WEBHOOK_SECRET` **ne sont pas gérés ici**. +- Ils doivent rester dans les secrets d'environnement serveur (Docker/env/vault). + +--- + +## Section 2 — Quotas par palier + +Vous pouvez définir, pour chaque tier (**BASIC**, **PRO**, **BUSINESS**, **ENTERPRISE**) : + +- **Indisponible** : fonctionnalité non accessible +- **Limité** : plafond mensuel (nombre entier >= 0) +- **Illimité** : pas de plafond + +### Procédure + +1. Cliquez sur le tier ciblé. +2. Pour chaque fonctionnalité : + - choisissez le mode d'accès + - si mode **Limité**, entrez la valeur mensuelle +3. Cliquez sur **Enregistrer** sur la ligne concernée. + +### Délai d'effet + +Les changements sont appliqués rapidement (cache), en général sous **~60 secondes**. + +--- + +## Section 3 — Aperçu consommation + +La section affiche : + +- usage par fonctionnalité (requêtes + tokens) +- top utilisateurs +- date de dernière synchronisation + +### Source des données + +- Compteurs temps réel : Redis +- Consolidation historique : table `UsageLog` +- Sync via cron (`/api/cron/sync-usage`) + +Si la synchronisation n'a pas encore tourné, l'interface peut indiquer un état non synchronisé. + +--- + +## Cas d'usage fréquents + +## 1) Augmenter temporairement le quota chat de PRO + +1. Tier: **PRO** +2. Ligne `chat` -> mode **Limité** +3. Mettre par exemple `50 -> 100` +4. **Enregistrer** + +## 2) Désactiver la vente (maintenance Stripe) + +1. Décochez **Activer la facturation** +2. **Enregistrer la config** +3. Vérifiez côté utilisateur: `/settings/billing` montre l'état désactivé + +## 3) Rendre une feature inaccessible en BASIC + +1. Tier: **BASIC** +2. Ligne feature -> mode **Indisponible** +3. **Enregistrer** + +--- + +## Validation rapide après changement + +Après une modification importante, vérifier : + +1. Le toast de succès en admin +2. Le comportement côté utilisateur (`/settings/billing` / appels IA) +3. Les logs serveur en cas d'erreur +4. L'`AuditLog` pour tracer qui a modifié quoi + +--- + +## Dépannage + +## "Le bouton Enregistrer ne semble rien faire" + +- Vérifiez que vous êtes bien connecté en ADMIN. +- Regardez le toast d'erreur. +- Vérifiez les logs serveur (action rejetée, validation invalide, etc.). + +## "Les quotas ne changent pas immédiatement" + +- Attendez ~60 secondes (cache). +- Relancez une action IA qui consomme réellement le quota. +- Vérifiez que la feature est bien en mode attendu (indisponible/limité/illimité). + +## "Le checkout ne démarre pas" + +- Vérifiez les 4 `STRIPE_PRICE_*` IDs. +- Vérifiez que la facturation est activée. +- Vérifiez que les secrets Stripe serveur sont présents (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`). + +--- + +## Bonnes pratiques d'exploitation + +- Éviter les changements massifs sans validation progressive. +- Journaliser les changements critiques (tiers très utilisés). +- Préférer des paliers de quota cohérents entre fonctionnalités similaires. +- Garder un rituel de revue hebdo des usages (section Aperçu consommation). + +--- + +## Référence technique (fichiers clés) + +- `app/(admin)/admin/billing/page.tsx` +- `app/(admin)/admin/billing/billing-admin-client.tsx` +- `app/actions/admin-billing.ts` +- `lib/plan-entitlements.ts` +- `lib/entitlements.ts` +- `lib/billing/stripe-prices.ts` +- `app/api/billing/status/route.ts` + diff --git a/memento-note/lib/ai/tools/slides.tool.ts b/memento-note/lib/ai/tools/slides.tool.ts index b5975bb..168c5d2 100644 --- a/memento-note/lib/ai/tools/slides.tool.ts +++ b/memento-note/lib/ai/tools/slides.tool.ts @@ -5,8 +5,6 @@ import { z } from 'zod' import { toolRegistry } from './registry' import { prisma } from '@/lib/prisma' import { buildPresentationHTML } from './slides-html-builder' -import { incrementUsageAsync } from '@/lib/entitlements' - const slideSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }), z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }), @@ -86,9 +84,6 @@ RULES: console.log('[Slides Tool] Canvas created:', canvas.id, '| Slides:', cappedSlides.length, '| Size:', Math.round(html.length / 1024), 'KB') - // Decrement slide_generate quota after successful canvas creation - incrementUsageAsync(ctx.userId, 'slide_generate') - if (ctx.actionId) { await prisma.agentAction.update({ where: { id: ctx.actionId }, diff --git a/memento-note/lib/audit-log.ts b/memento-note/lib/audit-log.ts index 1457ced..16d2a21 100644 --- a/memento-note/lib/audit-log.ts +++ b/memento-note/lib/audit-log.ts @@ -10,6 +10,9 @@ export type AuditAction = | 'PASSWORD_RESET' | 'AI_CONSENT_GRANTED' | 'AI_CONSENT_REVOKED' + | 'SUBSCRIPTION_OVERRIDE' + | 'BILLING_CONFIG_UPDATED' + | 'PLAN_ENTITLEMENT_UPDATED' export interface AuditLogParams { userId?: string | null diff --git a/memento-note/lib/billing/stripe-prices.ts b/memento-note/lib/billing/stripe-prices.ts index f23bcb2..361f442 100644 --- a/memento-note/lib/billing/stripe-prices.ts +++ b/memento-note/lib/billing/stripe-prices.ts @@ -1,5 +1,6 @@ -import type { SubscriptionTier } from '@/lib/entitlements'; +import type { SubscriptionTier } from '@/lib/plan-entitlements'; import { stripe } from '@/lib/stripe'; +import { getConfigValue } from '@/lib/config'; export type BillingTier = 'PRO' | 'BUSINESS'; export type BillingInterval = 'month' | 'year'; @@ -21,6 +22,14 @@ export const DEFAULT_PRICES: Record { + const flag = await getConfigValue('BILLING_ENABLED', ''); + if (flag === 'true') return true; + if (flag === 'false') return false; + return process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' + || process.env.NODE_ENV === 'development'; +} + export async function getDynamicPrices(): Promise>> { const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'; if (isMock) { @@ -40,13 +49,12 @@ export async function getDynamicPrices(): Promise { try { - const priceId = resolvePriceId(tier, interval); + const priceId = await resolvePriceId(tier, interval); const price = await stripe.prices.retrieve(priceId); if (price.unit_amount !== null && price.unit_amount !== undefined) { const amount = price.unit_amount / 100; const currency = price.currency.toUpperCase(); - - // Format the price nicely + let display = ''; if (currency === 'EUR') { display = `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`; @@ -57,12 +65,11 @@ export async function getDynamicPrices(): Promise> = { - PRO: { - month: process.env.STRIPE_PRICE_PRO_MONTHLY!, - year: process.env.STRIPE_PRICE_PRO_ANNUAL!, - }, - BUSINESS: { - month: process.env.STRIPE_PRICE_BUSINESS_MONTHLY!, - year: process.env.STRIPE_PRICE_BUSINESS_ANNUAL!, - }, - }; - const priceId = map[tier][interval]; - if (!priceId) { - const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'; - if (isMock && process.env.NODE_ENV !== 'test') { - return `price_mock_${tier.toLowerCase()}_${interval}`; - } - throw new Error(`No Stripe price ID configured for ${tier}/${interval}`); +const PRICE_ENV_KEYS: Record> = { + PRO: { + month: 'STRIPE_PRICE_PRO_MONTHLY', + year: 'STRIPE_PRICE_PRO_ANNUAL', + }, + BUSINESS: { + month: 'STRIPE_PRICE_BUSINESS_MONTHLY', + year: 'STRIPE_PRICE_BUSINESS_ANNUAL', + }, +}; + +export async function resolvePriceId(tier: BillingTier, interval: BillingInterval): Promise { + const configKey = PRICE_ENV_KEYS[tier][interval]; + const fromDb = await getConfigValue(configKey, ''); + const priceId = fromDb || process.env[configKey] || ''; + if (priceId) return priceId; + + const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'; + if (isMock && process.env.NODE_ENV !== 'test') { + return `price_mock_${tier.toLowerCase()}_${interval}`; } - return priceId; + throw new Error(`No Stripe price ID configured for ${tier}/${interval}`); } -export function priceIdToTier(priceId: string): SubscriptionTier | null { +export async function priceIdToTier(priceId: string): Promise { if (priceId && priceId.startsWith('price_mock_')) { if (priceId.includes('pro')) return 'PRO'; if (priceId.includes('business')) return 'BUSINESS'; return 'BASIC'; } - const entries: Array<[string | undefined, SubscriptionTier]> = [ - [process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'], - [process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'], - [process.env.STRIPE_PRICE_BUSINESS_MONTHLY, 'BUSINESS'], - [process.env.STRIPE_PRICE_BUSINESS_ANNUAL, 'BUSINESS'], + const entries: Array<[string, SubscriptionTier]> = [ + [(await getConfigValue('STRIPE_PRICE_PRO_MONTHLY', '')) || process.env.STRIPE_PRICE_PRO_MONTHLY || '', 'PRO'], + [(await getConfigValue('STRIPE_PRICE_PRO_ANNUAL', '')) || process.env.STRIPE_PRICE_PRO_ANNUAL || '', 'PRO'], + [(await getConfigValue('STRIPE_PRICE_BUSINESS_MONTHLY', '')) || process.env.STRIPE_PRICE_BUSINESS_MONTHLY || '', 'BUSINESS'], + [(await getConfigValue('STRIPE_PRICE_BUSINESS_ANNUAL', '')) || process.env.STRIPE_PRICE_BUSINESS_ANNUAL || '', 'BUSINESS'], ]; for (const [envPriceId, tier] of entries) { if (envPriceId && envPriceId === priceId) return tier; diff --git a/memento-note/lib/billing/sync-subscription-from-stripe.ts b/memento-note/lib/billing/sync-subscription-from-stripe.ts index 5305217..44966d8 100644 --- a/memento-note/lib/billing/sync-subscription-from-stripe.ts +++ b/memento-note/lib/billing/sync-subscription-from-stripe.ts @@ -27,7 +27,7 @@ export async function syncSubscriptionFromStripe( userId: string, ): Promise { const priceId = subscription.items.data[0]?.price?.id ?? null; - const resolvedTier = priceId ? priceIdToTier(priceId) : null; + const resolvedTier = priceId ? await priceIdToTier(priceId) : null; const tierFromMetadata = (subscription.metadata?.tier as string | undefined) ?? diff --git a/memento-note/lib/config.ts b/memento-note/lib/config.ts index 333d3b9..2da2077 100644 --- a/memento-note/lib/config.ts +++ b/memento-note/lib/config.ts @@ -56,6 +56,11 @@ const ENV_FALLBACKS: Record = { // Auth / misc ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION || '', NEXTAUTH_URL: process.env.NEXTAUTH_URL || '', + BILLING_ENABLED: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED || process.env.BILLING_ENABLED || '', + STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY || '', + STRIPE_PRICE_PRO_ANNUAL: process.env.STRIPE_PRICE_PRO_ANNUAL || '', + STRIPE_PRICE_BUSINESS_MONTHLY: process.env.STRIPE_PRICE_BUSINESS_MONTHLY || '', + STRIPE_PRICE_BUSINESS_ANNUAL: process.env.STRIPE_PRICE_BUSINESS_ANNUAL || '', } export async function getSystemConfig() { diff --git a/memento-note/lib/editor/markdown-export.ts b/memento-note/lib/editor/markdown-export.ts index 64d00e4..4faf8e7 100644 --- a/memento-note/lib/editor/markdown-export.ts +++ b/memento-note/lib/editor/markdown-export.ts @@ -121,7 +121,7 @@ interface BlockPlaceholder { function preprocessCustomNodes(html: string): { html: string; placeholders: BlockPlaceholder[] } { const placeholders: BlockPlaceholder[] = [] - // liveBlock:
+ // liveBlock let result = html.replace( /]*?data-live-block[^>]*?)>\s*<\/div>/gi, (_match, attrs) => { @@ -133,7 +133,7 @@ function preprocessCustomNodes(html: string): { html: string; placeholders: Bloc } ) - // structuredViewBlock:
+ // structuredViewBlock result = result.replace( /]*?data-structured-view-block[^>]*?)>\s*<\/div>/gi, (_match, attrs) => { @@ -149,6 +149,71 @@ function preprocessCustomNodes(html: string): { html: string; placeholders: Bloc } ) + // toggleBlock — preserve as HTML comment + result = result.replace( + /]*?data-type="toggle-block"[^>]*?)>([\s\S]*?)<\/div>/gi, + (_match, attrs, content) => { + const opened = attrs.match(/data-opened="([^"]*)"/i)?.[1] || 'true' + const key = `${SENTINEL_PREFIX}TOGGLE${placeholders.length}` + placeholders.push({ key, comment: `${content}\n` }) + return `

${key}

` + } + ) + + // calloutBlock + result = result.replace( + /]*?data-type="callout-block"[^>]*?)>([\s\S]*?)<\/div>/gi, + (_match, attrs, content) => { + const type = attrs.match(/data-callout-type="([^"]*)"/i)?.[1] || 'info' + const key = `${SENTINEL_PREFIX}CALLOUT${placeholders.length}` + placeholders.push({ key, comment: `${content}\n` }) + return `

${key}

` + } + ) + + // mathEquationBlock + result = result.replace( + /]*?data-type="math-equation"[^>]*?)>([\s\S]*?)<\/div>/gi, + (_match, attrs, content) => { + const latex = attrs.match(/data-latex="([^"]*)"/i)?.[1] || content.trim() + const key = `${SENTINEL_PREFIX}MATH${placeholders.length}` + placeholders.push({ key, comment: `` }) + return `

${key}

` + } + ) + + // outlineBlock + result = result.replace( + /]*data-type="outline-block"[^>]*><\/div>/gi, + () => { + const key = `${SENTINEL_PREFIX}OUTLINE${placeholders.length}` + placeholders.push({ key, comment: `` }) + return `

${key}

` + } + ) + + // columns + result = result.replace( + /]*?data-type="columns"[^>]*?)>([\s\S]*?)<\/div>/gi, + (_match, attrs, content) => { + const cols = attrs.match(/cols="([^"]*)"/i)?.[1] || '2' + const key = `${SENTINEL_PREFIX}COLUMNS${placeholders.length}` + placeholders.push({ key, comment: `${content}\n` }) + return `

${key}

` + } + ) + + // linkPreviewBlock + result = result.replace( + /]*?data-type="link-preview-block"[^>]*?)>[\s\S]*?<\/div>/gi, + (_match, attrs) => { + const url = attrs.match(/data-url="([^"]*)"/i)?.[1] || '' + const key = `${SENTINEL_PREFIX}LINKPREVIEW${placeholders.length}` + placeholders.push({ key, comment: `` }) + return `

${key}

` + } + ) + return { html: result, placeholders } } @@ -188,10 +253,10 @@ export function tiptapHTMLToMarkdown(html: string): string { export function markdownToHTML(markdown: string): string { if (!markdown || markdown.trim() === '') return '' - // marked v18+ uses synchronous parse by default when no async tokens + // breaks: true — single \n becomes
, matching WYSIWYG expectations const html = marked.parse(markdown, { gfm: true, - breaks: false, + breaks: true, }) as string return html diff --git a/memento-note/lib/entitlements.ts b/memento-note/lib/entitlements.ts index d9a8113..e0dd800 100644 --- a/memento-note/lib/entitlements.ts +++ b/memento-note/lib/entitlements.ts @@ -7,8 +7,13 @@ import { parseRedisInt, isValidFeature, } from './quota-utils'; - -type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE'; +import { + getLimitAsync, + getTierFeaturesAsync, + invalidateEntitlementCache, + TIER_LIMITS, + type SubscriptionTier, +} from './plan-entitlements'; export interface EntitlementResult { allowed: boolean; @@ -70,79 +75,8 @@ export class QuotaExceededError extends Error { } } -const TIER_LIMITS: Record> = { - BASIC: { - semantic_search: 30, - auto_tag: 15, - auto_title: 5, - brainstorm_create: 1, - brainstorm_expand: 10, - brainstorm_enrich: 20, - suggest_charts: 5, - ai_flashcard: 5, - voice_transcribe: 20, - }, - PRO: { - semantic_search: 200, - auto_tag: 500, - auto_title: 200, - reformulate: 50, - chat: 50, - brainstorm_create: 5, - brainstorm_expand: 100, - brainstorm_enrich: 200, - suggest_charts: 50, - slide_generate: 20, - excalidraw_generate: 20, - ai_flashcard: 100, - voice_transcribe: 500, - }, - BUSINESS: { - semantic_search: 1000, - auto_tag: 1000, - auto_title: 1000, - reformulate: 500, - chat: 500, - brainstorm_create: 'unlimited', - brainstorm_expand: 500, - brainstorm_enrich: 1000, - suggest_charts: 200, - slide_generate: 100, - excalidraw_generate: 100, - ai_flashcard: 'unlimited', - voice_transcribe: 'unlimited', - }, - ENTERPRISE: { - semantic_search: 'unlimited', - auto_tag: 'unlimited', - auto_title: 'unlimited', - reformulate: 'unlimited', - chat: 'unlimited', - brainstorm_create: 'unlimited', - brainstorm_expand: 'unlimited', - brainstorm_enrich: 'unlimited', - suggest_charts: 'unlimited', - slide_generate: 'unlimited', - excalidraw_generate: 'unlimited', - ai_flashcard: 'unlimited', - voice_transcribe: 'unlimited', - }, -}; - const TTL_SECONDS = 90 * 24 * 60 * 60; -const INCREMENT_LUA = ` -local current = tonumber(redis.call('GET', KEYS[1]) or '0') -local ttl = tonumber(ARGV[1]) -redis.call('INCRBY', KEYS[1], 1) -local ttlResult = redis.call('TTL', KEYS[1]) -if ttlResult == -1 then - redis.call('EXPIRE', KEYS[1], ttl) -end -local newCount = tonumber(redis.call('GET', KEYS[1])) -return newCount -`; - const INCREMENT_BY_LUA = ` local count = tonumber(ARGV[1]) or 1 local ttl = tonumber(ARGV[2]) @@ -171,14 +105,6 @@ local newCount = tonumber(redis.call('GET', KEYS[1])) return newCount `; -function getLimit(tier: SubscriptionTier, feature: string): number | undefined { - const tierLimits = TIER_LIMITS[tier]; - const limit = tierLimits?.[feature]; - if (limit === 'unlimited') return Infinity; - if (limit === undefined) return undefined; - return limit; -} - export async function getUserInfo( userId: string, ): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> { @@ -223,7 +149,7 @@ export async function canUseFeature( } const tier = await getEffectiveTier(userId); - const limit = getLimit(tier, feature); + const limit = await getLimitAsync(tier, feature); if (limit === undefined) { return { @@ -300,7 +226,7 @@ export async function reserveUsageOrThrow( } const tier = await getEffectiveTier(userId); - const limit = getLimit(tier, feature); + const limit = await getLimitAsync(tier, feature); if (limit === undefined) { throw new QuotaExceededError( @@ -374,8 +300,8 @@ export async function getUserQuotas( userId: string, ): Promise> { const tier = await getEffectiveTier(userId); + const features = await getTierFeaturesAsync(tier); const period = getCurrentPeriodKey(); - const features = Object.keys(TIER_LIMITS[tier]); if (features.length === 0) return {}; @@ -387,7 +313,7 @@ export async function getUserQuotas( const result: Record = {}; for (let i = 0; i < features.length; i++) { const feature = features[i]; - const limit = getLimit(tier, feature) ?? 0; + const limit = (await getLimitAsync(tier, feature)) ?? 0; const current = parseRedisInt(values[i]); result[feature] = { remaining: limit === Infinity ? Infinity : Math.max(0, limit - current), @@ -401,12 +327,17 @@ export async function getUserQuotas( console.error('[entitlements] getUserQuotas Redis error:', err); const result: Record = {}; for (const feature of features) { - const limit = getLimit(tier, feature) ?? 0; + const limit = (await getLimitAsync(tier, feature)) ?? 0; result[feature] = { remaining: limit, limit, used: 0 }; } return result; } } -export { TIER_LIMITS, getLimit }; +export { TIER_LIMITS, invalidateEntitlementCache }; export type { SubscriptionTier }; + +/** @deprecated Use getLimitAsync — sync helper for tests only */ +export async function getLimit(tier: SubscriptionTier, feature: string): Promise { + return getLimitAsync(tier, feature); +} diff --git a/memento-note/lib/plan-entitlements.ts b/memento-note/lib/plan-entitlements.ts new file mode 100644 index 0000000..bb7a04b --- /dev/null +++ b/memento-note/lib/plan-entitlements.ts @@ -0,0 +1,199 @@ +import { prisma } from './prisma'; +import { VALID_FEATURES } from './quota-utils'; + +export type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE'; + +/** Hardcoded defaults — used when DB is empty or unavailable. */ +export const FALLBACK_TIER_LIMITS: Record< + SubscriptionTier, + Record +> = { + BASIC: { + semantic_search: 30, + auto_tag: 15, + auto_title: 5, + brainstorm_create: 1, + brainstorm_expand: 10, + brainstorm_enrich: 20, + suggest_charts: 5, + ai_flashcard: 5, + voice_transcribe: 20, + }, + PRO: { + semantic_search: 200, + auto_tag: 500, + auto_title: 200, + reformulate: 50, + chat: 50, + brainstorm_create: 5, + brainstorm_expand: 100, + brainstorm_enrich: 200, + suggest_charts: 50, + slide_generate: 20, + excalidraw_generate: 20, + ai_flashcard: 100, + voice_transcribe: 500, + }, + BUSINESS: { + semantic_search: 1000, + auto_tag: 1000, + auto_title: 1000, + reformulate: 500, + chat: 500, + brainstorm_create: 'unlimited', + brainstorm_expand: 500, + brainstorm_enrich: 1000, + suggest_charts: 200, + slide_generate: 100, + excalidraw_generate: 100, + ai_flashcard: 'unlimited', + voice_transcribe: 'unlimited', + }, + ENTERPRISE: { + semantic_search: 'unlimited', + auto_tag: 'unlimited', + auto_title: 'unlimited', + reformulate: 'unlimited', + chat: 'unlimited', + brainstorm_create: 'unlimited', + brainstorm_expand: 'unlimited', + brainstorm_enrich: 'unlimited', + suggest_charts: 'unlimited', + slide_generate: 'unlimited', + excalidraw_generate: 'unlimited', + ai_flashcard: 'unlimited', + voice_transcribe: 'unlimited', + }, +}; + +const CACHE_TTL_MS = 60_000; + +type LimitMap = Record>; + +let cachedLimits: LimitMap | null = null; +let cacheExpiresAt = 0; + +function fallbackToLimitMap(): LimitMap { + const map = {} as LimitMap; + for (const tier of Object.keys(FALLBACK_TIER_LIMITS) as SubscriptionTier[]) { + map[tier] = {}; + for (const [feature, limit] of Object.entries(FALLBACK_TIER_LIMITS[tier])) { + map[tier][feature] = limit === 'unlimited' ? Infinity : limit; + } + } + return map; +} + +function buildLimitMapFromRows( + rows: Array<{ tier: SubscriptionTier; feature: string; limitValue: number | null }>, +): LimitMap { + const map = {} as LimitMap; + for (const tier of ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] as SubscriptionTier[]) { + map[tier] = {}; + } + + for (const row of rows) { + map[row.tier][row.feature] = + row.limitValue === null ? Infinity : row.limitValue; + } + + return map; +} + +export function invalidateEntitlementCache(): void { + cachedLimits = null; + cacheExpiresAt = 0; +} + +async function loadLimitMap(): Promise { + const now = Date.now(); + if (cachedLimits && now < cacheExpiresAt) { + return cachedLimits; + } + + try { + const rows = await prisma.planEntitlement.findMany({ + select: { tier: true, feature: true, limitValue: true }, + }); + + if (rows.length === 0) { + cachedLimits = fallbackToLimitMap(); + } else { + cachedLimits = buildLimitMapFromRows(rows as Array<{ + tier: SubscriptionTier; + feature: string; + limitValue: number | null; + }>); + } + } catch (err) { + console.error('[plan-entitlements] DB load failed, using fallback:', err); + cachedLimits = fallbackToLimitMap(); + } + + cacheExpiresAt = now + CACHE_TTL_MS; + return cachedLimits; +} + +export async function getLimitAsync( + tier: SubscriptionTier, + feature: string, +): Promise { + const map = await loadLimitMap(); + const limit = map[tier]?.[feature]; + if (limit === undefined) return undefined; + if (limit === Infinity) return Infinity; + return limit; +} + +export async function getTierFeaturesAsync( + tier: SubscriptionTier, +): Promise { + const map = await loadLimitMap(); + const fromDb = Object.keys(map[tier] ?? {}); + if (fromDb.length > 0) return fromDb; + return Object.keys(FALLBACK_TIER_LIMITS[tier]); +} + +export async function getAllEntitlementsForAdmin(): Promise< + Array<{ tier: SubscriptionTier; feature: string; limitValue: number | null; mode: 'limited' | 'unlimited' | 'unavailable' }> +> { + const rows = await prisma.planEntitlement.findMany({ + orderBy: [{ tier: 'asc' }, { feature: 'asc' }], + }); + + if (rows.length > 0) { + return rows.map((r) => ({ + tier: r.tier as SubscriptionTier, + feature: r.feature, + limitValue: r.limitValue, + mode: + r.limitValue === null + ? ('unlimited' as const) + : ('limited' as const), + })); + } + + const seeded: Array<{ + tier: SubscriptionTier; + feature: string; + limitValue: number | null; + mode: 'limited' | 'unlimited' | 'unavailable'; + }> = []; + + for (const tier of Object.keys(FALLBACK_TIER_LIMITS) as SubscriptionTier[]) { + for (const feature of VALID_FEATURES) { + const raw = FALLBACK_TIER_LIMITS[tier][feature]; + if (raw === undefined) continue; + seeded.push({ + tier, + feature, + limitValue: raw === 'unlimited' ? null : raw, + mode: raw === 'unlimited' ? 'unlimited' : 'limited', + }); + } + } + + return seeded; +} + +export { FALLBACK_TIER_LIMITS as TIER_LIMITS }; diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 8d69a05..eb6438b 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -1453,6 +1453,7 @@ "dashboard": "Dashboard", "users": "Users", "aiManagement": "AI Management", + "billing": "Billing & Quotas", "published": "Published pages", "chat": "AI Chat", "lab": "The Lab (Ideas)", @@ -1481,6 +1482,40 @@ "testSearch": "Test web search" }, "settingsDescription": "Configure application-wide settings", + "billing": { + "title": "Billing & Quotas", + "description": "Manage subscription tiers, AI quotas, and Stripe business settings without redeploying.", + "stripeConfigTitle": "Stripe business config", + "stripeConfigDescription": "Price IDs and billing toggle. Secret keys stay in server environment only.", + "enableBilling": "Enable billing UI and checkout", + "STRIPE_PRICE_PRO_MONTHLY": "Pro — monthly price ID", + "STRIPE_PRICE_PRO_ANNUAL": "Pro — annual price ID", + "STRIPE_PRICE_BUSINESS_MONTHLY": "Business — monthly price ID", + "STRIPE_PRICE_BUSINESS_ANNUAL": "Business — annual price ID", + "secretsNote": "STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET must remain in Docker/env secrets — never stored here.", + "saveConfig": "Save billing config", + "configSaved": "Billing configuration saved", + "configFailed": "Failed to save billing configuration", + "limitsTitle": "Tier quotas", + "limitsDescription": "Monthly request limits per feature. Changes apply within ~60 seconds.", + "feature": "Feature", + "mode": "Access", + "monthlyLimit": "Monthly limit", + "actions": "Actions", + "modeUnavailable": "Not available", + "modeLimited": "Limited", + "modeUnlimited": "Unlimited", + "saveLimit": "Save", + "limitSaved": "Quota updated", + "limitFailed": "Failed to update quota", + "usageTitle": "Usage overview", + "usagePeriod": "Period {period}", + "lastSync": "Last sync {date}", + "notSynced": "Not synced yet (cron /api/cron/sync-usage)", + "byFeature": "By feature (PostgreSQL)", + "topUsers": "Top users", + "noUsageData": "No usage data for this period yet" + }, "dashboard": { "title": "Dashboard", "description": "Overview of your application metrics", @@ -3072,6 +3107,7 @@ }, "billing": { "title": "Billing", + "disabledByAdmin": "Billing and upgrades are currently disabled. Contact your administrator if you need access.", "currentPlan": "Current Plan", "upgradePlan": "Upgrade Plan", "manageBilling": "Manage Billing", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 111c714..6592a82 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -1459,6 +1459,7 @@ "dashboard": "Tableau de bord", "users": "Utilisateurs", "aiManagement": "Gestion IA", + "billing": "Facturation & quotas", "published": "Pages publiées", "chat": "Chat IA", "lab": "Le Lab (Idées)", @@ -1487,6 +1488,40 @@ "testSearch": "Test recherche web" }, "settingsDescription": "Configurer les paramètres de l'application", + "billing": { + "title": "Facturation & quotas", + "description": "Gérez les paliers, quotas IA et la config Stripe métier sans redéploiement.", + "stripeConfigTitle": "Config Stripe (métier)", + "stripeConfigDescription": "Identifiants de prix et activation de la facturation. Les clés secrètes restent dans l'environnement serveur.", + "enableBilling": "Activer l'interface et le paiement Stripe", + "STRIPE_PRICE_PRO_MONTHLY": "Pro — prix mensuel (price ID)", + "STRIPE_PRICE_PRO_ANNUAL": "Pro — prix annuel (price ID)", + "STRIPE_PRICE_BUSINESS_MONTHLY": "Business — prix mensuel (price ID)", + "STRIPE_PRICE_BUSINESS_ANNUAL": "Business — prix annuel (price ID)", + "secretsNote": "STRIPE_SECRET_KEY et STRIPE_WEBHOOK_SECRET doivent rester dans Docker/env — jamais stockés ici.", + "saveConfig": "Enregistrer la config", + "configSaved": "Configuration enregistrée", + "configFailed": "Échec de l'enregistrement", + "limitsTitle": "Quotas par palier", + "limitsDescription": "Limites mensuelles par fonctionnalité. Prise en effet en ~60 s.", + "feature": "Fonctionnalité", + "mode": "Accès", + "monthlyLimit": "Limite mensuelle", + "actions": "Actions", + "modeUnavailable": "Indisponible", + "modeLimited": "Limité", + "modeUnlimited": "Illimité", + "saveLimit": "Enregistrer", + "limitSaved": "Quota mis à jour", + "limitFailed": "Échec de la mise à jour", + "usageTitle": "Aperçu consommation", + "usagePeriod": "Période {period}", + "lastSync": "Dernière sync {date}", + "notSynced": "Pas encore synchronisé (cron /api/cron/sync-usage)", + "byFeature": "Par fonctionnalité (PostgreSQL)", + "topUsers": "Utilisateurs les plus actifs", + "noUsageData": "Aucune donnée pour cette période" + }, "dashboard": { "title": "Tableau de bord", "description": "Vue d'ensemble des métriques de l'application", @@ -3076,6 +3111,7 @@ }, "billing": { "title": "Facturation", + "disabledByAdmin": "La facturation et les upgrades sont désactivées. Contactez l'administrateur si besoin.", "currentPlan": "Plan actuel", "upgradePlan": "Changer de plan", "manageBilling": "Gérer la facturation", diff --git a/memento-note/prisma/migrations/20260620120000_add_plan_entitlement/migration.sql b/memento-note/prisma/migrations/20260620120000_add_plan_entitlement/migration.sql new file mode 100644 index 0000000..bc7b660 --- /dev/null +++ b/memento-note/prisma/migrations/20260620120000_add_plan_entitlement/migration.sql @@ -0,0 +1,68 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "PlanEntitlement" ( + "id" TEXT NOT NULL, + "tier" "SubscriptionTier" NOT NULL, + "feature" TEXT NOT NULL, + "limitValue" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PlanEntitlement_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "PlanEntitlement_tier_feature_key" ON "PlanEntitlement"("tier", "feature"); +CREATE INDEX IF NOT EXISTS "PlanEntitlement_tier_idx" ON "PlanEntitlement"("tier"); + +-- Seed defaults (only when table is empty) +INSERT INTO "PlanEntitlement" ("id", "tier", "feature", "limitValue") +SELECT v.id, v.tier::"SubscriptionTier", v.feature, v."limitValue" +FROM (VALUES + ('pe-basic-semantic_search', 'BASIC', 'semantic_search', 30), + ('pe-basic-auto_tag', 'BASIC', 'auto_tag', 15), + ('pe-basic-auto_title', 'BASIC', 'auto_title', 5), + ('pe-basic-brainstorm_create', 'BASIC', 'brainstorm_create', 1), + ('pe-basic-brainstorm_expand', 'BASIC', 'brainstorm_expand', 10), + ('pe-basic-brainstorm_enrich', 'BASIC', 'brainstorm_enrich', 20), + ('pe-basic-suggest_charts', 'BASIC', 'suggest_charts', 5), + ('pe-basic-ai_flashcard', 'BASIC', 'ai_flashcard', 5), + ('pe-basic-voice_transcribe', 'BASIC', 'voice_transcribe', 20), + ('pe-pro-semantic_search', 'PRO', 'semantic_search', 200), + ('pe-pro-auto_tag', 'PRO', 'auto_tag', 500), + ('pe-pro-auto_title', 'PRO', 'auto_title', 200), + ('pe-pro-reformulate', 'PRO', 'reformulate', 50), + ('pe-pro-chat', 'PRO', 'chat', 50), + ('pe-pro-brainstorm_create', 'PRO', 'brainstorm_create', 5), + ('pe-pro-brainstorm_expand', 'PRO', 'brainstorm_expand', 100), + ('pe-pro-brainstorm_enrich', 'PRO', 'brainstorm_enrich', 200), + ('pe-pro-suggest_charts', 'PRO', 'suggest_charts', 50), + ('pe-pro-slide_generate', 'PRO', 'slide_generate', 20), + ('pe-pro-excalidraw_generate', 'PRO', 'excalidraw_generate', 20), + ('pe-pro-ai_flashcard', 'PRO', 'ai_flashcard', 100), + ('pe-pro-voice_transcribe', 'PRO', 'voice_transcribe', 500), + ('pe-business-semantic_search', 'BUSINESS', 'semantic_search', 1000), + ('pe-business-auto_tag', 'BUSINESS', 'auto_tag', 1000), + ('pe-business-auto_title', 'BUSINESS', 'auto_title', 1000), + ('pe-business-reformulate', 'BUSINESS', 'reformulate', 500), + ('pe-business-chat', 'BUSINESS', 'chat', 500), + ('pe-business-brainstorm_create', 'BUSINESS', 'brainstorm_create', NULL), + ('pe-business-brainstorm_expand', 'BUSINESS', 'brainstorm_expand', 500), + ('pe-business-brainstorm_enrich', 'BUSINESS', 'brainstorm_enrich', 1000), + ('pe-business-suggest_charts', 'BUSINESS', 'suggest_charts', 200), + ('pe-business-slide_generate', 'BUSINESS', 'slide_generate', 100), + ('pe-business-excalidraw_generate', 'BUSINESS', 'excalidraw_generate', 100), + ('pe-business-ai_flashcard', 'BUSINESS', 'ai_flashcard', NULL), + ('pe-business-voice_transcribe', 'BUSINESS', 'voice_transcribe', NULL), + ('pe-enterprise-semantic_search', 'ENTERPRISE', 'semantic_search', NULL), + ('pe-enterprise-auto_tag', 'ENTERPRISE', 'auto_tag', NULL), + ('pe-enterprise-auto_title', 'ENTERPRISE', 'auto_title', NULL), + ('pe-enterprise-reformulate', 'ENTERPRISE', 'reformulate', NULL), + ('pe-enterprise-chat', 'ENTERPRISE', 'chat', NULL), + ('pe-enterprise-brainstorm_create', 'ENTERPRISE', 'brainstorm_create', NULL), + ('pe-enterprise-brainstorm_expand', 'ENTERPRISE', 'brainstorm_expand', NULL), + ('pe-enterprise-brainstorm_enrich', 'ENTERPRISE', 'brainstorm_enrich', NULL), + ('pe-enterprise-suggest_charts', 'ENTERPRISE', 'suggest_charts', NULL), + ('pe-enterprise-slide_generate', 'ENTERPRISE', 'slide_generate', NULL), + ('pe-enterprise-excalidraw_generate', 'ENTERPRISE', 'excalidraw_generate', NULL), + ('pe-enterprise-ai_flashcard', 'ENTERPRISE', 'ai_flashcard', NULL), + ('pe-enterprise-voice_transcribe', 'ENTERPRISE', 'voice_transcribe', NULL) +) AS v(id, tier, feature, "limitValue") +WHERE NOT EXISTS (SELECT 1 FROM "PlanEntitlement" LIMIT 1); diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 6f9f0e6..0b26dcc 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -820,6 +820,18 @@ model FeatureFlag { updatedAt DateTime @updatedAt } +model PlanEntitlement { + id String @id @default(cuid()) + tier SubscriptionTier + feature String + limitValue Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([tier, feature]) + @@index([tier]) +} + // ===== CLUSTERING & BRIDGE NOTES ===== model NoteCluster { diff --git a/memento-note/tests/unit/billing-price-map.test.ts b/memento-note/tests/unit/billing-price-map.test.ts index 0643dcf..17c0cfe 100644 --- a/memento-note/tests/unit/billing-price-map.test.ts +++ b/memento-note/tests/unit/billing-price-map.test.ts @@ -1,6 +1,14 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { resolvePriceId, priceIdToTier } from '@/lib/billing/stripe-prices'; +vi.mock('@/lib/prisma', () => ({ + default: { + systemConfig: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, +})); + describe('billing-price-map', () => { const originalEnv = process.env; @@ -19,55 +27,55 @@ describe('billing-price-map', () => { }); describe('resolvePriceId', () => { - it('returns PRO monthly price ID', () => { - expect(resolvePriceId('PRO', 'month')).toBe('price_pro_monthly_test'); + it('returns PRO monthly price ID', async () => { + expect(await resolvePriceId('PRO', 'month')).toBe('price_pro_monthly_test'); }); - it('returns PRO annual price ID', () => { - expect(resolvePriceId('PRO', 'year')).toBe('price_pro_annual_test'); + it('returns PRO annual price ID', async () => { + expect(await resolvePriceId('PRO', 'year')).toBe('price_pro_annual_test'); }); - it('returns BUSINESS monthly price ID', () => { - expect(resolvePriceId('BUSINESS', 'month')).toBe('price_business_monthly_test'); + it('returns BUSINESS monthly price ID', async () => { + expect(await resolvePriceId('BUSINESS', 'month')).toBe('price_business_monthly_test'); }); - it('returns BUSINESS annual price ID', () => { - expect(resolvePriceId('BUSINESS', 'year')).toBe('price_business_annual_test'); + it('returns BUSINESS annual price ID', async () => { + expect(await resolvePriceId('BUSINESS', 'year')).toBe('price_business_annual_test'); }); - it('throws if env variable is not set', () => { + it('throws if env variable is not set', async () => { delete process.env.STRIPE_PRICE_PRO_MONTHLY; - expect(() => resolvePriceId('PRO', 'month')).toThrow(); + await expect(resolvePriceId('PRO', 'month')).rejects.toThrow(); }); }); describe('priceIdToTier', () => { - it('resolves PRO monthly price ID to PRO tier', () => { - expect(priceIdToTier('price_pro_monthly_test')).toBe('PRO'); + it('resolves PRO monthly price ID to PRO tier', async () => { + expect(await priceIdToTier('price_pro_monthly_test')).toBe('PRO'); }); - it('resolves PRO annual price ID to PRO tier', () => { - expect(priceIdToTier('price_pro_annual_test')).toBe('PRO'); + it('resolves PRO annual price ID to PRO tier', async () => { + expect(await priceIdToTier('price_pro_annual_test')).toBe('PRO'); }); - it('resolves BUSINESS monthly price ID to BUSINESS tier', () => { - expect(priceIdToTier('price_business_monthly_test')).toBe('BUSINESS'); + it('resolves BUSINESS monthly price ID to BUSINESS tier', async () => { + expect(await priceIdToTier('price_business_monthly_test')).toBe('BUSINESS'); }); - it('resolves BUSINESS annual price ID to BUSINESS tier', () => { - expect(priceIdToTier('price_business_annual_test')).toBe('BUSINESS'); + it('resolves BUSINESS annual price ID to BUSINESS tier', async () => { + expect(await priceIdToTier('price_business_annual_test')).toBe('BUSINESS'); }); - it('returns null for unknown price ID', () => { - expect(priceIdToTier('price_unknown_xyz')).toBeNull(); + it('returns null for unknown price ID', async () => { + expect(await priceIdToTier('price_unknown_xyz')).toBeNull(); }); - it('returns null when env variables are not set', () => { + it('returns null when env variables are not set', async () => { delete process.env.STRIPE_PRICE_PRO_MONTHLY; delete process.env.STRIPE_PRICE_PRO_ANNUAL; delete process.env.STRIPE_PRICE_BUSINESS_MONTHLY; delete process.env.STRIPE_PRICE_BUSINESS_ANNUAL; - expect(priceIdToTier('price_pro_monthly_test')).toBeNull(); + expect(await priceIdToTier('price_pro_monthly_test')).toBeNull(); }); }); }); diff --git a/memento-note/tests/unit/entitlements.test.ts b/memento-note/tests/unit/entitlements.test.ts index 67269d1..5dea940 100644 --- a/memento-note/tests/unit/entitlements.test.ts +++ b/memento-note/tests/unit/entitlements.test.ts @@ -22,6 +22,9 @@ vi.mock('@/lib/prisma', () => ({ subscription: { findUnique: vi.fn(), }, + planEntitlement: { + findMany: vi.fn().mockResolvedValue([]), + }, }, }));