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