fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
1. replaceAll (Find & Replace) — une seule transaction ProseMirror au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés. 2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs qui laissaient un nœud fantôme invisible dans le document. 3. Conversion Markdown → richtext — breaks: true dans marked.parse() Les simple newlines sont maintenant convertis en <br>. + préserve les blocs custom (toggle, callout, math, columns, outline, link-preview) en commentaires HTML lors de l'export MD. 4. emitNoteChange exercices — shape corrigée (type:'created' attend un objet Note, pas noteId/notebookId séparés). 5. Raccourcis clavier sans conflit : Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier) Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets) Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"lastRunAtMs": 1779998560332,
|
"lastRunAtMs": 1779998560332,
|
||||||
"turnsSinceLastRun": 4,
|
"turnsSinceLastRun": 9,
|
||||||
"lastTranscriptMtimeMs": 1779998515529,
|
"lastTranscriptMtimeMs": 1779998515529,
|
||||||
"lastProcessedGenerationId": "f664ecb0-412b-4c4e-b3ba-15f553a6b686",
|
"lastProcessedGenerationId": "3fb0d1db-af2c-46f8-b80f-c068ff52d0b9",
|
||||||
"trialStartedAtMs": null
|
"trialStartedAtMs": null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
---
|
||||||
|
|
||||||
|
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
</frozen-after-approval>
|
||||||
|
|
||||||
|
## 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)
|
||||||
303
memento-note/app/(admin)/admin/billing/billing-admin-client.tsx
Normal file
303
memento-note/app/(admin)/admin/billing/billing-admin-client.tsx
Normal file
@@ -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<string, string>
|
||||||
|
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<string | null>(null)
|
||||||
|
|
||||||
|
const handleSaveBilling = async (formData: FormData) => {
|
||||||
|
setIsSavingBilling(true)
|
||||||
|
try {
|
||||||
|
const data: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-memento-serif text-2xl font-semibold">{t('admin.billing.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<CreditCard className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.stripeConfigTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="BILLING_ENABLED"
|
||||||
|
checked={billingEnabled}
|
||||||
|
onCheckedChange={(c) => setBillingEnabled(!!c)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||||
|
placeholder="price_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
|
||||||
|
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<Settings2 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{initialData.tiers.map((tier) => (
|
||||||
|
<button
|
||||||
|
key={tier}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTier(tier)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
activeTier === tier
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tier}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
|
||||||
|
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
|
||||||
|
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
|
||||||
|
<th className="py-2">{t('admin.billing.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initialData.features.map((feature) => {
|
||||||
|
const row = getEntitlement(initialData.entitlements, activeTier, feature)
|
||||||
|
const mode = row?.mode ?? 'unavailable'
|
||||||
|
const cellKey = `${activeTier}:${feature}`
|
||||||
|
return (
|
||||||
|
<EntitlementRowEditor
|
||||||
|
key={`${activeTier}:${feature}`}
|
||||||
|
feature={feature}
|
||||||
|
mode={mode}
|
||||||
|
limitValue={row?.limitValue ?? null}
|
||||||
|
isSaving={savingCell === cellKey}
|
||||||
|
onSave={handleEntitlementChange}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<Gauge className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.usageTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{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')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid gap-6 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.byFeature')}</h3>
|
||||||
|
{initialData.usageOverview.byFeature.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.noUsageData')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{initialData.usageOverview.byFeature.map((row) => (
|
||||||
|
<li key={row.feature} className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||||
|
<span className="font-mono text-xs">{row.feature}</span>
|
||||||
|
<span className="text-muted-foreground">{row.requests} req · {row.tokens} tok</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.topUsers')}</h3>
|
||||||
|
{initialData.usageOverview.topUsers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.noUsageData')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{initialData.usageOverview.topUsers.map((row) => (
|
||||||
|
<li key={row.userId} className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||||
|
<span className="truncate">{row.email}</span>
|
||||||
|
<span className="text-muted-foreground shrink-0">{row.requests} req</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void>
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => 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 (
|
||||||
|
<tr className="border-b border-border/30">
|
||||||
|
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
|
||||||
|
<option value="limited">{t('admin.billing.modeLimited')}</option>
|
||||||
|
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{mode === 'limited' ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(e.target.value)}
|
||||||
|
className="h-9 w-24 text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
|
||||||
|
>
|
||||||
|
{isSaving ? '…' : t('admin.billing.saveLimit')}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
memento-note/app/(admin)/admin/billing/page.tsx
Normal file
9
memento-note/app/(admin)/admin/billing/page.tsx
Normal file
@@ -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 <BillingAdminClient initialData={data} />
|
||||||
|
}
|
||||||
215
memento-note/app/actions/admin-billing.ts
Normal file
215
memento-note/app/actions/admin-billing.ts
Normal file
@@ -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<string, string>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ export async function updateUserRole(userId: string, newRole: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserSubscription(userId: string, tier: string) {
|
export async function updateUserSubscription(userId: string, tier: string) {
|
||||||
await checkAdmin()
|
const session = await checkAdmin()
|
||||||
|
|
||||||
const validTiers: string[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
|
const validTiers: string[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
|
||||||
if (!validTiers.includes(tier)) {
|
if (!validTiers.includes(tier)) {
|
||||||
@@ -148,6 +148,9 @@ export async function updateUserSubscription(userId: string, tier: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const existing = await prisma.subscription.findUnique({ where: { userId } })
|
||||||
|
const oldTier = existing?.tier ?? 'BASIC'
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const periodEnd = new Date(now)
|
const periodEnd = new Date(now)
|
||||||
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
|
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
|
||||||
@@ -168,6 +171,15 @@ export async function updateUserSubscription(userId: string, tier: string) {
|
|||||||
currentPeriodEnd: periodEnd,
|
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')
|
revalidatePath('/admin')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { auth } from '@/auth'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { checkEntitlementOrThrow, incrementUsageAsync } from '@/lib/entitlements'
|
import { reserveUsageOrThrow } from '@/lib/entitlements'
|
||||||
import { toolRegistry } from '@/lib/ai/tools/registry'
|
import { toolRegistry } from '@/lib/ai/tools/registry'
|
||||||
|
|
||||||
// S'assurer que l'outil est importé pour s'enregistrer dans le registre
|
// 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 {
|
try {
|
||||||
// 1. Vérification et déduction des quotas
|
// 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
|
// 2. Instancier le modèle de chat IA
|
||||||
const systemConfig = await getSystemConfig()
|
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é." }
|
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 }
|
return { success: true, canvasId: result.canvasId }
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { embeddingService } from '@/lib/ai/services/embedding.service'
|
|||||||
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
||||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
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 { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import {
|
import {
|
||||||
@@ -521,7 +521,7 @@ export async function createNote(data: {
|
|||||||
const merged = [...new Set([...existingNames, ...appliedLabels])]
|
const merged = [...new Set([...existingNames, ...appliedLabels])]
|
||||||
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
|
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
|
||||||
// Incrémenter le quota une seule fois par sauvegarde où des labels IA sont appliqués
|
// 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) {
|
if (!data.skipRevalidation) {
|
||||||
revalidatePath('/home')
|
revalidatePath('/home')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
|
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export interface SemanticSearchResponse {
|
export interface SemanticSearchResponse {
|
||||||
results: SearchResult[]
|
results: SearchResult[]
|
||||||
@@ -25,12 +25,11 @@ export async function semanticSearch(
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'semantic_search');
|
await reserveUsageOrThrow(session.user.id, 'semantic_search');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) throw err;
|
if (err instanceof QuotaExceededError) throw err;
|
||||||
console.error('[semantic-search] Quota check error (fail-open):', err);
|
console.error('[semantic-search] Quota check error (fail-open):', err);
|
||||||
}
|
}
|
||||||
incrementUsageAsync(session.user.id, 'semantic_search');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
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'
|
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||||
|
|
||||||
type GenerateType = 'slide-generator' | 'excalidraw-generator'
|
type GenerateType = 'slide-generator' | 'excalidraw-generator'
|
||||||
@@ -48,7 +48,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// Quota check — feature key depends on generation type
|
// Quota check — feature key depends on generation type
|
||||||
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
|
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(userId, featureKey)
|
await reserveUsageOrThrow(userId, featureKey)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof QuotaExceededError) {
|
if (e instanceof QuotaExceededError) {
|
||||||
return NextResponse.json({ error: e.message }, { status: 402 })
|
return NextResponse.json({ error: e.message }, { status: 402 })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service'
|
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'
|
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
const isTierLocked = err.currentQuota === 0
|
||||||
@@ -88,7 +88,6 @@ export async function POST(request: NextRequest) {
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
incrementUsageAsync(session.user.id, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
exercises: createdNotes.map(n => ({ id: n.id, title: n.title })),
|
exercises: createdNotes.map(n => ({ id: n.id, title: n.title })),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { mathFromTextService } from '@/lib/ai/services/math-from-text.service'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
const isTierLocked = err.currentQuota === 0
|
||||||
@@ -29,7 +29,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latex = await mathFromTextService.generate(description)
|
const latex = await mathFromTextService.generate(description)
|
||||||
incrementUsageAsync(session.user.id, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json({ latex })
|
return NextResponse.json({ latex })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { notebookWizardService, type WizardProfile } from '@/lib/ai/services/notebook-wizard.service'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
const isTierLocked = err.currentQuota === 0
|
||||||
@@ -120,7 +120,6 @@ export async function POST(request: NextRequest) {
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
incrementUsageAsync(session.user.id, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
notebookId: notebook.id,
|
notebookId: notebook.id,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
|
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'
|
import { syncNoteLabels } from '@/app/actions/notes'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
const isTierLocked = err.currentQuota === 0
|
||||||
@@ -48,7 +48,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const result = await notebookOrganizerService.analyze(notesForAnalysis)
|
const result = await notebookOrganizerService.analyze(notesForAnalysis)
|
||||||
|
|
||||||
incrementUsageAsync(session.user.id, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json(result)
|
return NextResponse.json(result)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
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'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
||||||
@@ -90,7 +90,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Quota check (reuse reformulate quota)
|
// Quota check (reuse reformulate quota)
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
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 fullPrompt = `${persona.systemPrompt}\n\n---\nNOTE À ANALYSER :\n${plainText}`
|
||||||
const result = await provider.generateText(fullPrompt)
|
const result = await provider.generateText(fullPrompt)
|
||||||
|
|
||||||
incrementUsageAsync(session.user.id, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
personaId: persona.id,
|
personaId: persona.id,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
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 { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Check quota
|
// Check quota
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
const isTierLocked = err.currentQuota === 0
|
||||||
@@ -75,8 +75,6 @@ export async function POST(request: NextRequest) {
|
|||||||
// Use the ParagraphRefactorService
|
// Use the ParagraphRefactorService
|
||||||
const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language, writePrompt)
|
const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language, writePrompt)
|
||||||
|
|
||||||
incrementUsageAsync(session.user.id, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
originalText: result.original,
|
originalText: result.original,
|
||||||
reformulatedText: result.refactored,
|
reformulatedText: result.refactored,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { studyPlannerService } from '@/lib/ai/services/study-planner.service'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
const isTierLocked = err.currentQuota === 0
|
||||||
@@ -61,7 +61,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
incrementUsageAsync(session.user.id, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json(plan)
|
return NextResponse.json(plan)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user'
|
|||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export const maxDuration = 30
|
export const maxDuration = 30
|
||||||
@@ -56,7 +55,7 @@ export async function POST(req: Request) {
|
|||||||
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
||||||
console.log('[suggest-charts] BYOK:', willUseByok)
|
console.log('[suggest-charts] BYOK:', willUseByok)
|
||||||
if (!willUseByok) {
|
if (!willUseByok) {
|
||||||
await checkEntitlementOrThrow(userId, 'suggest_charts')
|
await reserveUsageOrThrow(userId, 'suggest_charts')
|
||||||
console.log('[suggest-charts] Quota OK')
|
console.log('[suggest-charts] Quota OK')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -243,9 +242,6 @@ Response format (COPY this structure):
|
|||||||
parsed.hasData = false
|
parsed.hasData = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track usage
|
|
||||||
await trackFeatureUsage(userId, 'suggest_charts', 'suggest-charts', 1)
|
|
||||||
|
|
||||||
return Response.json(parsed satisfies SuggestChartsResponse)
|
return Response.json(parsed satisfies SuggestChartsResponse)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.
|
|||||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user';
|
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user';
|
||||||
import { getSystemConfig } from '@/lib/config';
|
import { getSystemConfig } from '@/lib/config';
|
||||||
import { z } from 'zod';
|
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 { hasUserAiConsent } from '@/lib/consent/server-consent';
|
||||||
|
|
||||||
import { getAISettings } from '@/app/actions/ai-settings';
|
import { getAISettings } from '@/app/actions/ai-settings';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
|
|||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
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 { z } from 'zod'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
@@ -43,18 +43,6 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ suggestions: [] })
|
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 body = await req.json()
|
||||||
const { content: rawContent } = requestSchema.parse(body)
|
const { content: rawContent } = requestSchema.parse(body)
|
||||||
|
|
||||||
@@ -72,6 +60,17 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await getSystemConfig()
|
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)
|
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||||
const isPersian = /[\u0600-\u06FF]/.test(content)
|
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}]`
|
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',
|
'tags',
|
||||||
config,
|
config,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
(provider) => provider.generateTitles(titlePrompt),
|
(provider) => provider.generateTitles(titlePrompt),
|
||||||
)
|
)
|
||||||
if (!usedByok) incrementUsageAsync(session.user.id, 'auto_title')
|
|
||||||
|
|
||||||
// Créer les suggestions
|
// Créer les suggestions
|
||||||
const suggestions = titles.map((t: any) => ({
|
const suggestions = titles.map((t: any) => ({
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const userEmail = session.user.email;
|
const userEmail = session.user.email;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const priceId = resolvePriceId(tier, interval);
|
const priceId = await resolvePriceId(tier, interval);
|
||||||
|
|
||||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||||
let customerId = subscription?.stripeCustomerId ?? undefined;
|
let customerId = subscription?.stripeCustomerId ?? undefined;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth';
|
|||||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import type Stripe from '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';
|
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 sub = await stripe.subscriptions.retrieve(subId) as any;
|
||||||
const priceId = sub.items.data[0].price.id;
|
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 =
|
const currentPeriodStartTimestamp =
|
||||||
sub.current_period_start ??
|
sub.current_period_start ??
|
||||||
@@ -75,6 +75,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const effectiveTier = await getEffectiveTier(userId);
|
const effectiveTier = await getEffectiveTier(userId);
|
||||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||||
const prices = await getDynamicPrices();
|
const prices = await getDynamicPrices();
|
||||||
|
const billingEnabled = await isBillingEnabled();
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
tier,
|
tier,
|
||||||
@@ -85,6 +86,7 @@ export async function GET(req: NextRequest) {
|
|||||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||||
prices,
|
prices,
|
||||||
|
billingEnabled,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[billing/status]', error);
|
console.error('[billing/status]', error);
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
|
|||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||||
import {
|
import {
|
||||||
checkEntitlementOrThrow,
|
reserveUsageOrThrow,
|
||||||
QuotaExceededError,
|
QuotaExceededError,
|
||||||
incrementUsageAsync,
|
|
||||||
} from '@/lib/entitlements'
|
} from '@/lib/entitlements'
|
||||||
import { logActivity, captureSnapshot } from '@/lib/brainstorm-collab'
|
import { logActivity, captureSnapshot } from '@/lib/brainstorm-collab'
|
||||||
|
|
||||||
@@ -221,7 +220,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId)
|
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId)
|
||||||
if (!willUseByok) {
|
if (!willUseByok) {
|
||||||
await checkEntitlementOrThrow(userId, 'brainstorm_create')
|
await reserveUsageOrThrow(userId, 'brainstorm_create')
|
||||||
}
|
}
|
||||||
|
|
||||||
const classifiedNotes = await autoContextSearch(userId, seedIdea, contextNoteIds)
|
const classifiedNotes = await autoContextSearch(userId, seedIdea, contextNoteIds)
|
||||||
@@ -233,7 +232,6 @@ export async function POST(request: NextRequest) {
|
|||||||
userId,
|
userId,
|
||||||
(provider) => provider.generateText(prompt),
|
(provider) => provider.generateText(prompt),
|
||||||
)
|
)
|
||||||
if (!usedByok) incrementUsageAsync(userId, 'brainstorm_create')
|
|
||||||
|
|
||||||
let ideas: any[]
|
let ideas: any[]
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { auth } from '@/auth'
|
|||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||||
import { toolRegistry } from '@/lib/ai/tools'
|
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 { ByokUnavailableError } from '@/lib/byok'
|
||||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
@@ -66,7 +66,7 @@ export async function POST(req: Request) {
|
|||||||
const sysConfigEarly = await getSystemConfig()
|
const sysConfigEarly = await getSystemConfig()
|
||||||
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
||||||
if (!willUseByok) {
|
if (!willUseByok) {
|
||||||
await checkEntitlementOrThrow(userId, 'chat')
|
await reserveUsageOrThrow(userId, 'chat')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
@@ -483,7 +483,6 @@ Focus ONLY on this note unless asked otherwise.`
|
|||||||
})
|
})
|
||||||
if (!usedByok) {
|
if (!usedByok) {
|
||||||
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
|
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
|
||||||
incrementUsageAsync(userId, 'chat')
|
|
||||||
}
|
}
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
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 { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
||||||
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
||||||
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(session.user.id, 'ai_flashcard')
|
await reserveUsageOrThrow(session.user.id, 'ai_flashcard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
const isTierLocked = err.currentQuota === 0
|
const isTierLocked = err.currentQuota === 0
|
||||||
@@ -70,7 +70,6 @@ export async function POST(request: NextRequest) {
|
|||||||
language: note.language || undefined,
|
language: note.language || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
incrementUsageAsync(session.user.id, 'ai_flashcard')
|
|
||||||
|
|
||||||
return NextResponse.json({ cards, noteId: note.id, style })
|
return NextResponse.json({ cards, noteId: note.id, style })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
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<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar'> = {
|
const MODE_MAP: Record<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar'> = {
|
||||||
improve: 'improveStyle',
|
improve: 'improveStyle',
|
||||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!validation.valid) return NextResponse.json({ error: validation.error }, { status: 400 })
|
if (!validation.valid) return NextResponse.json({ error: validation.error }, { status: 400 })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(userId, 'reformulate')
|
await reserveUsageOrThrow(userId, 'reformulate')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 })
|
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)
|
const result = await paragraphRefactorService.refactor(text, refactorMode, 'markdown', undefined)
|
||||||
incrementUsageAsync(userId, 'reformulate')
|
|
||||||
|
|
||||||
return NextResponse.json({ improved: result.refactored, original: result.original })
|
return NextResponse.json({ improved: result.refactored, original: result.original })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
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 { getSystemConfig } from '@/lib/config'
|
||||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = getMobileUserId(req)
|
||||||
@@ -14,25 +14,27 @@ export async function POST(req: NextRequest) {
|
|||||||
const wordCount = content.split(/\s+/).length
|
const wordCount = content.split(/\s+/).length
|
||||||
if (wordCount < 5) return NextResponse.json({ error: 'Contenu trop court (min 5 mots)' }, { status: 400 })
|
if (wordCount < 5) return NextResponse.json({ error: 'Contenu trop court (min 5 mots)' }, { status: 400 })
|
||||||
|
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId)
|
||||||
|
if (!willUseByok) {
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(userId, 'auto_title')
|
await reserveUsageOrThrow(userId, 'auto_title')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 })
|
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 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',
|
'tags',
|
||||||
config,
|
config,
|
||||||
userId,
|
userId,
|
||||||
(provider) => provider.generateTitles(prompt),
|
(provider) => provider.generateTitles(prompt),
|
||||||
)
|
)
|
||||||
if (!usedByok) incrementUsageAsync(userId, 'auto_title')
|
|
||||||
|
|
||||||
return NextResponse.json({ suggestions: (titles ?? []).map((t: any) => t.title ?? t) })
|
return NextResponse.json({ suggestions: (titles ?? []).map((t: any) => t.title ?? t) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
||||||
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = getMobileUserId(req)
|
||||||
@@ -29,7 +29,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkEntitlementOrThrow(userId, 'ai_flashcard')
|
await reserveUsageOrThrow(userId, 'ai_flashcard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
return NextResponse.json({ error: err.currentQuota === 0 ? 'Fonctionnalité non disponible sur votre abonnement' : 'Quota IA atteint' }, { status: 402 })
|
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() } })
|
await prisma.flashcardDeck.update({ where: { id: deckId }, data: { updatedAt: new Date() } })
|
||||||
incrementUsageAsync(userId, 'ai_flashcard')
|
|
||||||
|
|
||||||
return NextResponse.json({ deckId, count: cards.length, cards })
|
return NextResponse.json({ deckId, count: cards.length, cards })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
LogOut,
|
LogOut,
|
||||||
Brain,
|
Brain,
|
||||||
|
CreditCard,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -42,6 +43,11 @@ const ADMIN_NAV_ITEMS = [
|
|||||||
href: '/admin/ai',
|
href: '/admin/ai',
|
||||||
icon: Brain,
|
icon: Brain,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.billing',
|
||||||
|
href: '/admin/billing',
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
titleKey: 'admin.sidebar.published',
|
titleKey: 'admin.sidebar.published',
|
||||||
href: '/admin/published',
|
href: '/admin/published',
|
||||||
|
|||||||
@@ -172,9 +172,14 @@ export function FindReplaceBar({ editor, onClose }: { editor: Editor; onClose: (
|
|||||||
const replaceAll = useCallback(() => {
|
const replaceAll = useCallback(() => {
|
||||||
const ms = matchesRef.current
|
const ms = matchesRef.current
|
||||||
if (ms.length === 0) return
|
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)
|
const sorted = [...ms].sort((a, b) => b.from - a.from)
|
||||||
editor.chain().focus()
|
// Use a single ProseMirror transaction for all replacements
|
||||||
sorted.forEach(m => editor.chain().insertContentAt({ from: m.from, to: m.to }, replaceText).run())
|
const tr = editor.state.tr
|
||||||
|
for (const m of sorted) {
|
||||||
|
tr.insertText(replaceText, m.from, m.to)
|
||||||
|
}
|
||||||
|
editor.view.dispatch(tr)
|
||||||
matchesRef.current = []
|
matchesRef.current = []
|
||||||
setCount(0)
|
setCount(0)
|
||||||
setCurrentIndex(-1)
|
setCurrentIndex(-1)
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
})
|
})
|
||||||
// Emit events so the note list refreshes
|
// Emit events so the note list refreshes
|
||||||
for (const ex of data.exercises || []) {
|
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: '<p></p>' } as any })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface BillingStatus {
|
|||||||
currentPeriodEnd: string | null;
|
currentPeriodEnd: string | null;
|
||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
hasStripeSubscription: boolean;
|
hasStripeSubscription: boolean;
|
||||||
|
billingEnabled?: boolean;
|
||||||
prices?: {
|
prices?: {
|
||||||
PRO: {
|
PRO: {
|
||||||
month: { display: string; amount: number; currency: string };
|
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<typeof loadStripe> | null = null;
|
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
||||||
function getStripePromise() {
|
function getStripePromise(enabled: boolean) {
|
||||||
if (!billingEnabled) return null;
|
if (!enabled) return null;
|
||||||
if (!stripePromise && process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
if (!stripePromise && process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
||||||
stripePromise = loadStripe(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 quotas = usageData?.quotas;
|
||||||
|
const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback;
|
||||||
|
const stripe = getStripePromise(billingEnabled);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -536,7 +539,7 @@ export function BillingPlans() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{billingEnabled && (
|
{billingEnabled ? (
|
||||||
<div className="flex items-center gap-2 justify-center">
|
<div className="flex items-center gap-2 justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -560,6 +563,10 @@ export function BillingPlans() {
|
|||||||
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.save')} ~17%</span>
|
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.save')} ~17%</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-sm text-muted-foreground px-4">
|
||||||
|
{t('billing.disabledByAdmin')}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -661,7 +668,7 @@ export function BillingPlans() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<EmbeddedCheckoutProvider
|
<EmbeddedCheckoutProvider
|
||||||
stripe={getStripePromise()}
|
stripe={stripe}
|
||||||
options={{ clientSecret: checkoutClientSecret, onComplete: handleCheckoutComplete }}
|
options={{ clientSecret: checkoutClientSecret, onComplete: handleCheckoutComplete }}
|
||||||
>
|
>
|
||||||
<EmbeddedCheckout />
|
<EmbeddedCheckout />
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export const CalloutExtension = Node.create({
|
|||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
'Mod-Shift-C': () => this.editor.commands.insertContent({
|
'Mod-Alt-C': () => this.editor.commands.insertContent({
|
||||||
type: this.name,
|
type: this.name,
|
||||||
attrs: { type: 'info' },
|
attrs: { type: 'info' },
|
||||||
content: [{ type: 'paragraph' }],
|
content: [{ type: 'paragraph' }],
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const ColumnsExtension = Node.create({
|
|||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
'Mod-Shift-L': () => this.editor.commands.insertContent({
|
'Mod-Alt-L': () => this.editor.commands.insertContent({
|
||||||
type: this.name,
|
type: this.name,
|
||||||
attrs: { cols: 2 },
|
attrs: { cols: 2 },
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const LinkPreviewView = ({ node, updateAttributes, deleteNode, selected }: any)
|
|||||||
}, [url, cached, updateAttributes])
|
}, [url, cached, updateAttributes])
|
||||||
|
|
||||||
const unwrap = () => {
|
const unwrap = () => {
|
||||||
updateAttributes({ url: '', preview: null })
|
deleteNode()
|
||||||
}
|
}
|
||||||
|
|
||||||
const domain = (() => {
|
const domain = (() => {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export const OutlineExtension = Node.create({
|
|||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
'Mod-Shift-O': () => this.editor.commands.insertContent({
|
'Mod-Alt-O': () => this.editor.commands.insertContent({
|
||||||
type: this.name,
|
type: this.name,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
161
memento-note/docs/admin-billing-quotas-guide.md
Normal file
161
memento-note/docs/admin-billing-quotas-guide.md
Normal file
@@ -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`
|
||||||
|
|
||||||
@@ -5,8 +5,6 @@ import { z } from 'zod'
|
|||||||
import { toolRegistry } from './registry'
|
import { toolRegistry } from './registry'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { buildPresentationHTML } from './slides-html-builder'
|
import { buildPresentationHTML } from './slides-html-builder'
|
||||||
import { incrementUsageAsync } from '@/lib/entitlements'
|
|
||||||
|
|
||||||
const slideSchema = z.discriminatedUnion('type', [
|
const slideSchema = z.discriminatedUnion('type', [
|
||||||
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
|
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()) }),
|
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')
|
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) {
|
if (ctx.actionId) {
|
||||||
await prisma.agentAction.update({
|
await prisma.agentAction.update({
|
||||||
where: { id: ctx.actionId },
|
where: { id: ctx.actionId },
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export type AuditAction =
|
|||||||
| 'PASSWORD_RESET'
|
| 'PASSWORD_RESET'
|
||||||
| 'AI_CONSENT_GRANTED'
|
| 'AI_CONSENT_GRANTED'
|
||||||
| 'AI_CONSENT_REVOKED'
|
| 'AI_CONSENT_REVOKED'
|
||||||
|
| 'SUBSCRIPTION_OVERRIDE'
|
||||||
|
| 'BILLING_CONFIG_UPDATED'
|
||||||
|
| 'PLAN_ENTITLEMENT_UPDATED'
|
||||||
|
|
||||||
export interface AuditLogParams {
|
export interface AuditLogParams {
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SubscriptionTier } from '@/lib/entitlements';
|
import type { SubscriptionTier } from '@/lib/plan-entitlements';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
|
import { getConfigValue } from '@/lib/config';
|
||||||
|
|
||||||
export type BillingTier = 'PRO' | 'BUSINESS';
|
export type BillingTier = 'PRO' | 'BUSINESS';
|
||||||
export type BillingInterval = 'month' | 'year';
|
export type BillingInterval = 'month' | 'year';
|
||||||
@@ -21,6 +22,14 @@ export const DEFAULT_PRICES: Record<BillingTier, Record<BillingInterval, Dynamic
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function isBillingEnabled(): Promise<boolean> {
|
||||||
|
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<Record<BillingTier, Record<BillingInterval, DynamicPrice>>> {
|
export async function getDynamicPrices(): Promise<Record<BillingTier, Record<BillingInterval, DynamicPrice>>> {
|
||||||
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
|
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
|
||||||
if (isMock) {
|
if (isMock) {
|
||||||
@@ -40,13 +49,12 @@ export async function getDynamicPrices(): Promise<Record<BillingTier, Record<Bil
|
|||||||
|
|
||||||
const retrieveAndFormatPrice = async (tier: BillingTier, interval: BillingInterval) => {
|
const retrieveAndFormatPrice = async (tier: BillingTier, interval: BillingInterval) => {
|
||||||
try {
|
try {
|
||||||
const priceId = resolvePriceId(tier, interval);
|
const priceId = await resolvePriceId(tier, interval);
|
||||||
const price = await stripe.prices.retrieve(priceId);
|
const price = await stripe.prices.retrieve(priceId);
|
||||||
if (price.unit_amount !== null && price.unit_amount !== undefined) {
|
if (price.unit_amount !== null && price.unit_amount !== undefined) {
|
||||||
const amount = price.unit_amount / 100;
|
const amount = price.unit_amount / 100;
|
||||||
const currency = price.currency.toUpperCase();
|
const currency = price.currency.toUpperCase();
|
||||||
|
|
||||||
// Format the price nicely
|
|
||||||
let display = '';
|
let display = '';
|
||||||
if (currency === 'EUR') {
|
if (currency === 'EUR') {
|
||||||
display = `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`;
|
display = `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`;
|
||||||
@@ -62,7 +70,6 @@ export async function getDynamicPrices(): Promise<Record<BillingTier, Record<Bil
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[stripe-prices] Failed to retrieve price for ${tier}/${interval}:`, err);
|
console.error(`[stripe-prices] Failed to retrieve price for ${tier}/${interval}:`, err);
|
||||||
// Fallback to default in case of error
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,40 +83,42 @@ export async function getDynamicPrices(): Promise<Record<BillingTier, Record<Bil
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePriceId(tier: BillingTier, interval: BillingInterval): string {
|
const PRICE_ENV_KEYS: Record<BillingTier, Record<BillingInterval, string>> = {
|
||||||
const map: Record<BillingTier, Record<BillingInterval, string>> = {
|
|
||||||
PRO: {
|
PRO: {
|
||||||
month: process.env.STRIPE_PRICE_PRO_MONTHLY!,
|
month: 'STRIPE_PRICE_PRO_MONTHLY',
|
||||||
year: process.env.STRIPE_PRICE_PRO_ANNUAL!,
|
year: 'STRIPE_PRICE_PRO_ANNUAL',
|
||||||
},
|
},
|
||||||
BUSINESS: {
|
BUSINESS: {
|
||||||
month: process.env.STRIPE_PRICE_BUSINESS_MONTHLY!,
|
month: 'STRIPE_PRICE_BUSINESS_MONTHLY',
|
||||||
year: process.env.STRIPE_PRICE_BUSINESS_ANNUAL!,
|
year: 'STRIPE_PRICE_BUSINESS_ANNUAL',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const priceId = map[tier][interval];
|
|
||||||
if (!priceId) {
|
export async function resolvePriceId(tier: BillingTier, interval: BillingInterval): Promise<string> {
|
||||||
|
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';
|
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
|
||||||
if (isMock && process.env.NODE_ENV !== 'test') {
|
if (isMock && process.env.NODE_ENV !== 'test') {
|
||||||
return `price_mock_${tier.toLowerCase()}_${interval}`;
|
return `price_mock_${tier.toLowerCase()}_${interval}`;
|
||||||
}
|
}
|
||||||
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
|
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
|
||||||
}
|
}
|
||||||
return priceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function priceIdToTier(priceId: string): SubscriptionTier | null {
|
export async function priceIdToTier(priceId: string): Promise<SubscriptionTier | null> {
|
||||||
if (priceId && priceId.startsWith('price_mock_')) {
|
if (priceId && priceId.startsWith('price_mock_')) {
|
||||||
if (priceId.includes('pro')) return 'PRO';
|
if (priceId.includes('pro')) return 'PRO';
|
||||||
if (priceId.includes('business')) return 'BUSINESS';
|
if (priceId.includes('business')) return 'BUSINESS';
|
||||||
return 'BASIC';
|
return 'BASIC';
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: Array<[string | undefined, SubscriptionTier]> = [
|
const entries: Array<[string, SubscriptionTier]> = [
|
||||||
[process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'],
|
[(await getConfigValue('STRIPE_PRICE_PRO_MONTHLY', '')) || process.env.STRIPE_PRICE_PRO_MONTHLY || '', 'PRO'],
|
||||||
[process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'],
|
[(await getConfigValue('STRIPE_PRICE_PRO_ANNUAL', '')) || process.env.STRIPE_PRICE_PRO_ANNUAL || '', 'PRO'],
|
||||||
[process.env.STRIPE_PRICE_BUSINESS_MONTHLY, 'BUSINESS'],
|
[(await getConfigValue('STRIPE_PRICE_BUSINESS_MONTHLY', '')) || process.env.STRIPE_PRICE_BUSINESS_MONTHLY || '', 'BUSINESS'],
|
||||||
[process.env.STRIPE_PRICE_BUSINESS_ANNUAL, 'BUSINESS'],
|
[(await getConfigValue('STRIPE_PRICE_BUSINESS_ANNUAL', '')) || process.env.STRIPE_PRICE_BUSINESS_ANNUAL || '', 'BUSINESS'],
|
||||||
];
|
];
|
||||||
for (const [envPriceId, tier] of entries) {
|
for (const [envPriceId, tier] of entries) {
|
||||||
if (envPriceId && envPriceId === priceId) return tier;
|
if (envPriceId && envPriceId === priceId) return tier;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function syncSubscriptionFromStripe(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const priceId = subscription.items.data[0]?.price?.id ?? null;
|
const priceId = subscription.items.data[0]?.price?.id ?? null;
|
||||||
const resolvedTier = priceId ? priceIdToTier(priceId) : null;
|
const resolvedTier = priceId ? await priceIdToTier(priceId) : null;
|
||||||
|
|
||||||
const tierFromMetadata =
|
const tierFromMetadata =
|
||||||
(subscription.metadata?.tier as string | undefined) ??
|
(subscription.metadata?.tier as string | undefined) ??
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const ENV_FALLBACKS: Record<string, string> = {
|
|||||||
// Auth / misc
|
// Auth / misc
|
||||||
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION || '',
|
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION || '',
|
||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || '',
|
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() {
|
export async function getSystemConfig() {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ interface BlockPlaceholder {
|
|||||||
function preprocessCustomNodes(html: string): { html: string; placeholders: BlockPlaceholder[] } {
|
function preprocessCustomNodes(html: string): { html: string; placeholders: BlockPlaceholder[] } {
|
||||||
const placeholders: BlockPlaceholder[] = []
|
const placeholders: BlockPlaceholder[] = []
|
||||||
|
|
||||||
// liveBlock: <div data-live-block="true" sourceNoteId="..." blockId="..."></div>
|
// liveBlock
|
||||||
let result = html.replace(
|
let result = html.replace(
|
||||||
/<div([^>]*?data-live-block[^>]*?)>\s*<\/div>/gi,
|
/<div([^>]*?data-live-block[^>]*?)>\s*<\/div>/gi,
|
||||||
(_match, attrs) => {
|
(_match, attrs) => {
|
||||||
@@ -133,7 +133,7 @@ function preprocessCustomNodes(html: string): { html: string; placeholders: Bloc
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// structuredViewBlock: <div data-structured-view-block="true" ...></div>
|
// structuredViewBlock
|
||||||
result = result.replace(
|
result = result.replace(
|
||||||
/<div([^>]*?data-structured-view-block[^>]*?)>\s*<\/div>/gi,
|
/<div([^>]*?data-structured-view-block[^>]*?)>\s*<\/div>/gi,
|
||||||
(_match, attrs) => {
|
(_match, attrs) => {
|
||||||
@@ -149,6 +149,71 @@ function preprocessCustomNodes(html: string): { html: string; placeholders: Bloc
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// toggleBlock — preserve as HTML comment
|
||||||
|
result = result.replace(
|
||||||
|
/<div([^>]*?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: `<!-- toggle: opened=${opened} -->${content}\n<!-- /toggle -->` })
|
||||||
|
return `<p>${key}</p>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// calloutBlock
|
||||||
|
result = result.replace(
|
||||||
|
/<div([^>]*?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: `<!-- callout: type=${type} -->${content}\n<!-- /callout -->` })
|
||||||
|
return `<p>${key}</p>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// mathEquationBlock
|
||||||
|
result = result.replace(
|
||||||
|
/<div([^>]*?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: `<!-- math: ${latex} -->` })
|
||||||
|
return `<p>${key}</p>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// outlineBlock
|
||||||
|
result = result.replace(
|
||||||
|
/<div[^>]*data-type="outline-block"[^>]*><\/div>/gi,
|
||||||
|
() => {
|
||||||
|
const key = `${SENTINEL_PREFIX}OUTLINE${placeholders.length}`
|
||||||
|
placeholders.push({ key, comment: `<!-- outline -->` })
|
||||||
|
return `<p>${key}</p>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// columns
|
||||||
|
result = result.replace(
|
||||||
|
/<div([^>]*?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: `<!-- columns: cols=${cols} -->${content}\n<!-- /columns -->` })
|
||||||
|
return `<p>${key}</p>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// linkPreviewBlock
|
||||||
|
result = result.replace(
|
||||||
|
/<div([^>]*?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: `<!-- link-preview: ${url} -->` })
|
||||||
|
return `<p>${key}</p>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return { html: result, placeholders }
|
return { html: result, placeholders }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,10 +253,10 @@ export function tiptapHTMLToMarkdown(html: string): string {
|
|||||||
export function markdownToHTML(markdown: string): string {
|
export function markdownToHTML(markdown: string): string {
|
||||||
if (!markdown || markdown.trim() === '') return ''
|
if (!markdown || markdown.trim() === '') return ''
|
||||||
|
|
||||||
// marked v18+ uses synchronous parse by default when no async tokens
|
// breaks: true — single \n becomes <br>, matching WYSIWYG expectations
|
||||||
const html = marked.parse(markdown, {
|
const html = marked.parse(markdown, {
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: false,
|
breaks: true,
|
||||||
}) as string
|
}) as string
|
||||||
|
|
||||||
return html
|
return html
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import {
|
|||||||
parseRedisInt,
|
parseRedisInt,
|
||||||
isValidFeature,
|
isValidFeature,
|
||||||
} from './quota-utils';
|
} from './quota-utils';
|
||||||
|
import {
|
||||||
type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE';
|
getLimitAsync,
|
||||||
|
getTierFeaturesAsync,
|
||||||
|
invalidateEntitlementCache,
|
||||||
|
TIER_LIMITS,
|
||||||
|
type SubscriptionTier,
|
||||||
|
} from './plan-entitlements';
|
||||||
|
|
||||||
export interface EntitlementResult {
|
export interface EntitlementResult {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
@@ -70,79 +75,8 @@ export class QuotaExceededError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>> = {
|
|
||||||
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 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 = `
|
const INCREMENT_BY_LUA = `
|
||||||
local count = tonumber(ARGV[1]) or 1
|
local count = tonumber(ARGV[1]) or 1
|
||||||
local ttl = tonumber(ARGV[2])
|
local ttl = tonumber(ARGV[2])
|
||||||
@@ -171,14 +105,6 @@ local newCount = tonumber(redis.call('GET', KEYS[1]))
|
|||||||
return newCount
|
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(
|
export async function getUserInfo(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> {
|
): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> {
|
||||||
@@ -223,7 +149,7 @@ export async function canUseFeature(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tier = await getEffectiveTier(userId);
|
const tier = await getEffectiveTier(userId);
|
||||||
const limit = getLimit(tier, feature);
|
const limit = await getLimitAsync(tier, feature);
|
||||||
|
|
||||||
if (limit === undefined) {
|
if (limit === undefined) {
|
||||||
return {
|
return {
|
||||||
@@ -300,7 +226,7 @@ export async function reserveUsageOrThrow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tier = await getEffectiveTier(userId);
|
const tier = await getEffectiveTier(userId);
|
||||||
const limit = getLimit(tier, feature);
|
const limit = await getLimitAsync(tier, feature);
|
||||||
|
|
||||||
if (limit === undefined) {
|
if (limit === undefined) {
|
||||||
throw new QuotaExceededError(
|
throw new QuotaExceededError(
|
||||||
@@ -374,8 +300,8 @@ export async function getUserQuotas(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
||||||
const tier = await getEffectiveTier(userId);
|
const tier = await getEffectiveTier(userId);
|
||||||
|
const features = await getTierFeaturesAsync(tier);
|
||||||
const period = getCurrentPeriodKey();
|
const period = getCurrentPeriodKey();
|
||||||
const features = Object.keys(TIER_LIMITS[tier]);
|
|
||||||
|
|
||||||
if (features.length === 0) return {};
|
if (features.length === 0) return {};
|
||||||
|
|
||||||
@@ -387,7 +313,7 @@ export async function getUserQuotas(
|
|||||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
||||||
for (let i = 0; i < features.length; i++) {
|
for (let i = 0; i < features.length; i++) {
|
||||||
const feature = features[i];
|
const feature = features[i];
|
||||||
const limit = getLimit(tier, feature) ?? 0;
|
const limit = (await getLimitAsync(tier, feature)) ?? 0;
|
||||||
const current = parseRedisInt(values[i]);
|
const current = parseRedisInt(values[i]);
|
||||||
result[feature] = {
|
result[feature] = {
|
||||||
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
|
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
|
||||||
@@ -401,12 +327,17 @@ export async function getUserQuotas(
|
|||||||
console.error('[entitlements] getUserQuotas Redis error:', err);
|
console.error('[entitlements] getUserQuotas Redis error:', err);
|
||||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
|
||||||
for (const feature of features) {
|
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 };
|
result[feature] = { remaining: limit, limit, used: 0 };
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TIER_LIMITS, getLimit };
|
export { TIER_LIMITS, invalidateEntitlementCache };
|
||||||
export type { SubscriptionTier };
|
export type { SubscriptionTier };
|
||||||
|
|
||||||
|
/** @deprecated Use getLimitAsync — sync helper for tests only */
|
||||||
|
export async function getLimit(tier: SubscriptionTier, feature: string): Promise<number | undefined> {
|
||||||
|
return getLimitAsync(tier, feature);
|
||||||
|
}
|
||||||
|
|||||||
199
memento-note/lib/plan-entitlements.ts
Normal file
199
memento-note/lib/plan-entitlements.ts
Normal file
@@ -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<string, number | 'unlimited'>
|
||||||
|
> = {
|
||||||
|
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<SubscriptionTier, Record<string, number | undefined>>;
|
||||||
|
|
||||||
|
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<LimitMap> {
|
||||||
|
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<number | undefined> {
|
||||||
|
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<string[]> {
|
||||||
|
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 };
|
||||||
@@ -1453,6 +1453,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"aiManagement": "AI Management",
|
"aiManagement": "AI Management",
|
||||||
|
"billing": "Billing & Quotas",
|
||||||
"published": "Published pages",
|
"published": "Published pages",
|
||||||
"chat": "AI Chat",
|
"chat": "AI Chat",
|
||||||
"lab": "The Lab (Ideas)",
|
"lab": "The Lab (Ideas)",
|
||||||
@@ -1481,6 +1482,40 @@
|
|||||||
"testSearch": "Test web search"
|
"testSearch": "Test web search"
|
||||||
},
|
},
|
||||||
"settingsDescription": "Configure application-wide settings",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"description": "Overview of your application metrics",
|
"description": "Overview of your application metrics",
|
||||||
@@ -3072,6 +3107,7 @@
|
|||||||
},
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Billing",
|
"title": "Billing",
|
||||||
|
"disabledByAdmin": "Billing and upgrades are currently disabled. Contact your administrator if you need access.",
|
||||||
"currentPlan": "Current Plan",
|
"currentPlan": "Current Plan",
|
||||||
"upgradePlan": "Upgrade Plan",
|
"upgradePlan": "Upgrade Plan",
|
||||||
"manageBilling": "Manage Billing",
|
"manageBilling": "Manage Billing",
|
||||||
|
|||||||
@@ -1459,6 +1459,7 @@
|
|||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"aiManagement": "Gestion IA",
|
"aiManagement": "Gestion IA",
|
||||||
|
"billing": "Facturation & quotas",
|
||||||
"published": "Pages publiées",
|
"published": "Pages publiées",
|
||||||
"chat": "Chat IA",
|
"chat": "Chat IA",
|
||||||
"lab": "Le Lab (Idées)",
|
"lab": "Le Lab (Idées)",
|
||||||
@@ -1487,6 +1488,40 @@
|
|||||||
"testSearch": "Test recherche web"
|
"testSearch": "Test recherche web"
|
||||||
},
|
},
|
||||||
"settingsDescription": "Configurer les paramètres de l'application",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"description": "Vue d'ensemble des métriques de l'application",
|
"description": "Vue d'ensemble des métriques de l'application",
|
||||||
@@ -3076,6 +3111,7 @@
|
|||||||
},
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Facturation",
|
"title": "Facturation",
|
||||||
|
"disabledByAdmin": "La facturation et les upgrades sont désactivées. Contactez l'administrateur si besoin.",
|
||||||
"currentPlan": "Plan actuel",
|
"currentPlan": "Plan actuel",
|
||||||
"upgradePlan": "Changer de plan",
|
"upgradePlan": "Changer de plan",
|
||||||
"manageBilling": "Gérer la facturation",
|
"manageBilling": "Gérer la facturation",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -820,6 +820,18 @@ model FeatureFlag {
|
|||||||
updatedAt DateTime @updatedAt
|
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 =====
|
// ===== CLUSTERING & BRIDGE NOTES =====
|
||||||
|
|
||||||
model NoteCluster {
|
model NoteCluster {
|
||||||
|
|||||||
@@ -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';
|
import { resolvePriceId, priceIdToTier } from '@/lib/billing/stripe-prices';
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
default: {
|
||||||
|
systemConfig: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('billing-price-map', () => {
|
describe('billing-price-map', () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|
||||||
@@ -19,55 +27,55 @@ describe('billing-price-map', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolvePriceId', () => {
|
describe('resolvePriceId', () => {
|
||||||
it('returns PRO monthly price ID', () => {
|
it('returns PRO monthly price ID', async () => {
|
||||||
expect(resolvePriceId('PRO', 'month')).toBe('price_pro_monthly_test');
|
expect(await resolvePriceId('PRO', 'month')).toBe('price_pro_monthly_test');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns PRO annual price ID', () => {
|
it('returns PRO annual price ID', async () => {
|
||||||
expect(resolvePriceId('PRO', 'year')).toBe('price_pro_annual_test');
|
expect(await resolvePriceId('PRO', 'year')).toBe('price_pro_annual_test');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns BUSINESS monthly price ID', () => {
|
it('returns BUSINESS monthly price ID', async () => {
|
||||||
expect(resolvePriceId('BUSINESS', 'month')).toBe('price_business_monthly_test');
|
expect(await resolvePriceId('BUSINESS', 'month')).toBe('price_business_monthly_test');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns BUSINESS annual price ID', () => {
|
it('returns BUSINESS annual price ID', async () => {
|
||||||
expect(resolvePriceId('BUSINESS', 'year')).toBe('price_business_annual_test');
|
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;
|
delete process.env.STRIPE_PRICE_PRO_MONTHLY;
|
||||||
expect(() => resolvePriceId('PRO', 'month')).toThrow();
|
await expect(resolvePriceId('PRO', 'month')).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('priceIdToTier', () => {
|
describe('priceIdToTier', () => {
|
||||||
it('resolves PRO monthly price ID to PRO tier', () => {
|
it('resolves PRO monthly price ID to PRO tier', async () => {
|
||||||
expect(priceIdToTier('price_pro_monthly_test')).toBe('PRO');
|
expect(await priceIdToTier('price_pro_monthly_test')).toBe('PRO');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves PRO annual price ID to PRO tier', () => {
|
it('resolves PRO annual price ID to PRO tier', async () => {
|
||||||
expect(priceIdToTier('price_pro_annual_test')).toBe('PRO');
|
expect(await priceIdToTier('price_pro_annual_test')).toBe('PRO');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves BUSINESS monthly price ID to BUSINESS tier', () => {
|
it('resolves BUSINESS monthly price ID to BUSINESS tier', async () => {
|
||||||
expect(priceIdToTier('price_business_monthly_test')).toBe('BUSINESS');
|
expect(await priceIdToTier('price_business_monthly_test')).toBe('BUSINESS');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves BUSINESS annual price ID to BUSINESS tier', () => {
|
it('resolves BUSINESS annual price ID to BUSINESS tier', async () => {
|
||||||
expect(priceIdToTier('price_business_annual_test')).toBe('BUSINESS');
|
expect(await priceIdToTier('price_business_annual_test')).toBe('BUSINESS');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for unknown price ID', () => {
|
it('returns null for unknown price ID', async () => {
|
||||||
expect(priceIdToTier('price_unknown_xyz')).toBeNull();
|
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_MONTHLY;
|
||||||
delete process.env.STRIPE_PRICE_PRO_ANNUAL;
|
delete process.env.STRIPE_PRICE_PRO_ANNUAL;
|
||||||
delete process.env.STRIPE_PRICE_BUSINESS_MONTHLY;
|
delete process.env.STRIPE_PRICE_BUSINESS_MONTHLY;
|
||||||
delete process.env.STRIPE_PRICE_BUSINESS_ANNUAL;
|
delete process.env.STRIPE_PRICE_BUSINESS_ANNUAL;
|
||||||
expect(priceIdToTier('price_pro_monthly_test')).toBeNull();
|
expect(await priceIdToTier('price_pro_monthly_test')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ vi.mock('@/lib/prisma', () => ({
|
|||||||
subscription: {
|
subscription: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
|
planEntitlement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user