From 556a0b2f3fad7203ebc1ae75f11ac37b6c75cdbc Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 16 Jul 2026 20:43:18 +0000 Subject: [PATCH] feat(credits): solde IA unique (option M), packs Stripe et polish billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée. --- AGENTS.md | 6 +- .../admin/billing/billing-admin-client.tsx | 290 +- .../admin/settings/admin-settings-form.tsx | 85 +- memento-note/app/(admin)/admin/user-list.tsx | 36 +- memento-note/app/(auth)/layout.tsx | 53 +- memento-note/app/(main)/layout.tsx | 8 +- .../appearance/appearance-settings-client.tsx | 5 +- memento-note/app/(main)/template.tsx | 18 +- memento-note/app/(public)/layout.tsx | 14 +- memento-note/app/(public)/p/[slug]/page.tsx | 1066 ++++-- memento-note/app/actions/admin-billing.ts | 40 +- memento-note/app/actions/admin-settings.ts | 47 +- memento-note/app/actions/admin.ts | 57 + .../app/api/agents/run-for-note/route.ts | 82 +- .../api/billing/create-pack-checkout/route.ts | 135 + memento-note/app/api/billing/status/route.ts | 38 +- memento-note/app/api/billing/webhook/route.ts | 75 +- memento-note/app/api/usage/current/route.ts | 47 +- memento-note/app/globals.css | 16 +- memento-note/app/layout.tsx | 33 +- memento-note/components/block-action-menu.tsx | 7 +- .../components/contextual-ai-chat.tsx | 77 +- memento-note/components/lab/canvas-board.tsx | 87 +- memento-note/components/landing-page.tsx | 835 +++-- .../note-editor/note-editor-toolbar.tsx | 3 + .../components/notification-panel.tsx | 163 +- .../components/publish/copy-link-button.tsx | 42 + .../components/publish/reading-progress.tsx | 77 + .../components/settings/billing-plans.tsx | 312 +- .../components/settings/inline-paywall.tsx | 113 +- .../components/settings/usage-breakdown.tsx | 188 +- memento-note/components/sidebar.tsx | 8 +- memento-note/components/sw-cleanup.tsx | 16 + memento-note/components/theme-class-guard.tsx | 102 + memento-note/components/theme-initializer.tsx | 23 +- memento-note/components/usage-meter.tsx | 354 +- .../docs/admin-billing-quotas-guide.md | 205 +- memento-note/lib/ai-quota.ts | 80 +- memento-note/lib/ai/factory.ts | 35 + .../lib/ai/services/agent-executor.service.ts | 437 ++- .../lib/ai/services/slide-content-quality.ts | 643 ++++ .../services/slide-deck-generator.service.ts | 692 ++++ memento-note/lib/ai/services/slide-intent.ts | 151 + .../lib/ai/services/slide-source-assets.ts | 103 + .../lib/ai/tools/slides-html-builder.ts | 86 +- memento-note/lib/ai/tools/slides.tool.ts | 213 +- memento-note/lib/ai/types.ts | 6 + memento-note/lib/apply-document-theme.ts | 57 +- memento-note/lib/audit-log.ts | 1 + memento-note/lib/billing/credit-packs.ts | 158 + memento-note/lib/config.ts | 3 + memento-note/lib/credits.ts | 753 ++++ memento-note/lib/entitlements.ts | 222 +- memento-note/lib/inline-boot-scripts.ts | 58 + memento-note/lib/plan-entitlements.ts | 13 +- memento-note/lib/publish/shared-css.ts | 12 +- memento-note/lib/theme-script.ts | 20 +- memento-note/locales/ar.json | 2607 ++++++++++--- memento-note/locales/de.json | 2747 +++++++++++--- memento-note/locales/en.json | 453 ++- memento-note/locales/es.json | 2865 +++++++++++---- memento-note/locales/fa.json | 2560 ++++++++++--- memento-note/locales/fr.json | 453 ++- memento-note/locales/hi.json | 2957 +++++++++++---- memento-note/locales/it.json | 3273 ++++++++++++----- memento-note/locales/ja.json | 2749 +++++++++++--- memento-note/locales/ko.json | 2791 ++++++++++---- memento-note/locales/nl.json | 2791 ++++++++++---- memento-note/locales/pl.json | 2777 +++++++++++--- memento-note/locales/pt.json | 2771 +++++++++++--- memento-note/locales/ru.json | 2777 +++++++++++--- memento-note/locales/zh.json | 2735 +++++++++++--- .../migration.sql | 55 + memento-note/prisma/schema.prisma | 45 + memento-note/public/scripts/theme-init.js | 36 +- .../tests/unit/slide-deck-substance.test.ts | 94 + .../tests/unit/slide-source-assets.test.ts | 133 + 77 files changed, 35745 insertions(+), 10430 deletions(-) create mode 100644 memento-note/app/api/billing/create-pack-checkout/route.ts create mode 100644 memento-note/components/publish/copy-link-button.tsx create mode 100644 memento-note/components/publish/reading-progress.tsx create mode 100644 memento-note/components/sw-cleanup.tsx create mode 100644 memento-note/components/theme-class-guard.tsx create mode 100644 memento-note/lib/ai/services/slide-content-quality.ts create mode 100644 memento-note/lib/ai/services/slide-deck-generator.service.ts create mode 100644 memento-note/lib/ai/services/slide-intent.ts create mode 100644 memento-note/lib/ai/services/slide-source-assets.ts create mode 100644 memento-note/lib/billing/credit-packs.ts create mode 100644 memento-note/lib/credits.ts create mode 100644 memento-note/lib/inline-boot-scripts.ts create mode 100644 memento-note/prisma/migrations/20260716190000_add_ai_credit_tables/migration.sql create mode 100644 memento-note/tests/unit/slide-deck-substance.test.ts create mode 100644 memento-note/tests/unit/slide-source-assets.test.ts diff --git a/AGENTS.md b/AGENTS.md index 63a3bd2..85d58cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,15 +5,15 @@ - Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs. - Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR. - Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Memento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.** -- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles. +- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; chaque widget : **aide contextuelle « ? » i18n** ; couleurs alignées sur le **thème settings** ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; liens/ponts proposés : **pertinence sémantique réelle** (rejeter associations absurdes cross-domaine) ; **pas d'IDs techniques** type « cluster 15 » exposés à l'utilisateur ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles ; **landing page** (`components/landing-page.tsx`) : style marketing sombre/premium type **x.ai** (pas de fond blanc plat) ; mettre en avant **Second Brain** ; **multilinguisme** avec sélecteur de langue (icône globe — ne pas confondre avec navigation back) ; harmonie dark mode (ex. zone notifications). - Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue. - Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look). - **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu. - Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `` obligatoire pour la navigation interne, pas ``) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production. - Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env). -- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design. +- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design. - Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante. -- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local). +- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local). ## Learned Workspace Facts diff --git a/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx b/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx index 0d6d46f..6743ae7 100644 --- a/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx +++ b/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx @@ -1,24 +1,16 @@ 'use client' -import { useEffect, useState } from 'react' +import { 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 { updateBillingConfig } 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' -} +import { CreditCard, Gauge, Coins, Package } from 'lucide-react' type BillingAdminData = { - entitlements: EntitlementRow[] billingConfig: Record usageOverview: { period: string @@ -26,34 +18,55 @@ type BillingAdminData = { 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[] + /** Allocations mensuelles du solde unique (source de vérité débit) */ + creditAllocations?: Array<{ + tier: string + monthlyCredits: number | null + unlimited: boolean + }> + creditCosts?: Record + creditPacks?: Array<{ + id: string + credits: number + defaultDisplay: string + }> } -function getEntitlement( - entitlements: EntitlementRow[], - tier: string, - feature: string, -): EntitlementRow | undefined { - return entitlements.find((e) => e.tier === tier && e.feature === feature) +const SUBSCRIPTION_PRICE_KEYS = [ + 'STRIPE_PRICE_PRO_MONTHLY', + 'STRIPE_PRICE_PRO_ANNUAL', + 'STRIPE_PRICE_BUSINESS_MONTHLY', + 'STRIPE_PRICE_BUSINESS_ANNUAL', +] as const + +const PACK_PRICE_KEYS = [ + 'STRIPE_PRICE_CREDITS_S', + 'STRIPE_PRICE_CREDITS_M', + 'STRIPE_PRICE_CREDITS_L', +] as const + +/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */ +function formatStableUtc(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC` } export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) { const { t } = useLanguage() - const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC') const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true') const [isSavingBilling, setIsSavingBilling] = useState(false) - const [savingCell, setSavingCell] = useState(null) const handleSaveBilling = async (formData: FormData) => { setIsSavingBilling(true) try { const data: Record = { BILLING_ENABLED: billingEnabled ? 'true' : 'false', - STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''), - STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''), - STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''), - STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''), + } + for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) { + data[key] = String(formData.get(key) ?? '') } await updateBillingConfig(data) toast.success(t('admin.billing.configSaved')) @@ -64,23 +77,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD } } - 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 (
@@ -98,7 +94,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD

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

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

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

+
+ {SUBSCRIPTION_PRICE_KEYS.map((key) => ( +
+ + +
+ ))} +
+
+
+
+ +

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

+
+

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

+
+ {PACK_PRICE_KEYS.map((key) => ( +
+ + +
+ ))} +

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

+ {/* Solde unique — source de vérité du débit */}
- +
-

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

-

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

+

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

+

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

-
- {initialData.tiers.map((tier) => ( - +

+ {row.tier} +

+

+ {row.unlimited + ? t('admin.billing.modeUnlimited') + : row.monthlyCredits?.toLocaleString('fr-FR')} +

+ {!row.unlimited && ( +

+ {t('admin.billing.creditsPerMonth')} +

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

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

+
+ {(initialData.creditPacks ?? []).map((pack) => ( + + {pack.id} + + {pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay} + + + ))} +
+
+ )} +

+ {t('admin.billing.creditsCostsNote')} +

+

+ {t('admin.billing.creditsResetHint')} +

@@ -195,7 +215,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD

{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.lastSync', { + // Format fixe UTC (évite mismatch SSR locale/fuseau vs client) + date: formatStableUtc(initialData.usageOverview.lastSyncedAt), + })}` : ` · ${t('admin.billing.notSynced')}`}

@@ -236,68 +259,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD ) } - -function EntitlementRowEditor({ - feature, - mode: initialMode, - limitValue: initialLimit, - isSaving, - onSave, - t, -}: { - feature: string - mode: 'unavailable' | 'unlimited' | 'limited' - limitValue: number | null - isSaving: boolean - onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise - t: (key: string, params?: Record) => string -}) { - const [mode, setMode] = useState(initialMode) - const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50') - - useEffect(() => { - setMode(initialMode) - setLimit(initialLimit != null ? String(initialLimit) : '50') - }, [initialMode, initialLimit]) - - return ( - - {feature} - - - - - {mode === 'limited' ? ( - setLimit(e.target.value)} - className="h-9 w-24 text-xs" - /> - ) : ( - - )} - - - - - - ) -} diff --git a/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx b/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx index 85664b5..14531f0 100644 --- a/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx +++ b/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx @@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record = { zai: ['text-embedding-3-small', 'text-embedding-3-large'], } -type ModelPurpose = 'tags' | 'embeddings' | 'chat' +type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides' export function AdminSettingsForm({ config }: { config: Record }) { const { t } = useLanguage() - const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags') + const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags') const [isSaving, setIsSaving] = useState(false) const [isTesting, setIsTesting] = useState(false) @@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record } return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v }) const [chatProvider, setChatProvider] = useState((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama') + const [slidesProvider, setSlidesProvider] = useState( + (config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama', + ) // Selected Models State const [selectedTagsModel, setSelectedTagsModel] = useState(config.AI_MODEL_TAGS || '') const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState(config.AI_MODEL_EMBEDDING || '') const [selectedChatModel, setSelectedChatModel] = useState(config.AI_MODEL_CHAT || '') + const [selectedSlidesModel, setSelectedSlidesModel] = useState(config.AI_MODEL_SLIDES || '') const [tagsFallbackProvider, setTagsFallbackProvider] = useState(config.AI_PROVIDER_TAGS_FALLBACK || '') const [embedFallbackProvider, setEmbedFallbackProvider] = useState(config.AI_PROVIDER_EMBEDDING_FALLBACK || '') @@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record } tags: [], embeddings: [], chat: [], + slides: [], }) const [isLoadingModels, setIsLoadingModels] = useState>({ tags: false, embeddings: false, chat: false, + slides: false, }) // Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models @@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record } const key = config[API_KEY_CONFIG[chatProvider]] || '' if (url && key) await fetchModels('chat', chatProvider, url, key) } + + if (slidesProvider === 'ollama') { + await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434') + } else if (slidesProvider === 'lmstudio') { + await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1') + } else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') { + const url = DEFAULT_BASE_URLS[slidesProvider] + const key = config[API_KEY_CONFIG[slidesProvider]] || '' + if (url && key) await fetchModels('slides', slidesProvider, url, key) + } } fetchInitial() @@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record } } } + // Slides provider (dedicated model for deck generation — never auto-renamed) + const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string + data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? '' + const slidesModel = formData.get('AI_MODEL_SLIDES') as string + data.AI_MODEL_SLIDES = slidesModel?.trim() ?? '' + if (slidesProv) { + if (slidesProv === 'ollama') { + const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string + if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl + } else if (slidesProv === 'lmstudio') { + const url = formData.get('BASE_URL_lmstudio_slides') as string + if (url) data.LMSTUDIO_BASE_URL = url + } else { + const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider] + if (apiKeyConfigKey) { + const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string + if (apiKey) data[apiKeyConfigKey] = apiKey + } + const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider] + if (baseUrlConfigKey) { + const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string + if (baseUrl) data[baseUrlConfigKey] = baseUrl + } + } + } + const result = await updateSystemConfig(data) setIsSaving(false) @@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record } +
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record } />
+ + {/* Slides Provider — dedicated model for deck generation */} +
+

+ 📑 {t('admin.ai.slidesProvider')} +

+

+ {t('admin.ai.slidesDescription')} +

+ +
+ + +
+ + + {renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)} + +

+ {t('admin.ai.slidesQuotaNote')} +

+
diff --git a/memento-note/app/(admin)/admin/user-list.tsx b/memento-note/app/(admin)/admin/user-list.tsx index 1dd2702..82e5abe 100644 --- a/memento-note/app/(admin)/admin/user-list.tsx +++ b/memento-note/app/(admin)/admin/user-list.tsx @@ -3,9 +3,9 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { Button } from '@/components/ui/button' -import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin' +import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin' import { toast } from 'sonner' -import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react' +import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react' import { format } from 'date-fns' import { useLanguage } from '@/lib/i18n' @@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) { const { t } = useLanguage() const [users, setUsers] = useState(initialUsers) const [openDropdown, setOpenDropdown] = useState(null) + const [resettingId, setResettingId] = useState(null) const handleDelete = async (id: string) => { if (!confirm(t('admin.users.confirmDelete'))) return @@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) { } } + const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => { + const label = user.email || user.name || user.id + if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return + setResettingId(user.id) + try { + const result = await resetUserQuotas(user.id) + toast.success( + t('admin.users.resetQuotasSuccess', { + period: result.period, + count: result.featuresReset.length, + }), + ) + } catch (e) { + toast.error( + e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'), + ) + } finally { + setResettingId(null) + } + } + const handleRoleToggle = async (user: any) => { const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN' try { @@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) { {format(new Date(user.createdAt), 'PP')}
+