Quarante-huit chantiers clos entre le 5 et le 12 août 2026.
+ Cinquante-et-un chantiers clos entre le 5 et le 13 août 2026.
+
+ FAQ de la landing — le plan à 179 € s'appelait encore « Bundle »
+ 41 occurrences en 4 langues (FR, EN, NL, DE) dans src/data/segments.ts, alors que les cartes de tarifs disent « Premium » : un prospect qui lisait la grille puis la FAQ voyait deux noms pour la même offre. Les formes composées des langues germaniques suivent (Bundle-abonnementen → Premium-abonnementen, Bundle-Pläne → Premium-Pläne). Vérifié : plus aucun « Bundle » dans myinfomate-landing/src, et les 41 occurrences étaient toutes de la prose — aucun identifiant touché. npm run build vert.
+
+
+
+ Lot F — le bouton du portail Stripe et le diviseur de questions (manager-app)
+ Portail de facturation : le bouton manquait, et l'écran n'affichait rien du tout hors essai — if (isTrialActive) enveloppait le seul bouton, donc un client payant arrivait sur un écran sans action. C'est désormais Checkout pendant l'essai, portail après ; les deux ne coexistent jamais. ⚠️ Le 409 a son propre message : les 4 instances de la bascule viennent de Mongo et n'ont pas de StripeCustomerId — « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. 4 clés i18n FR/EN/NL.
+ Diviseur de questions : guide_ia_screen lit aiTokensPerQuestion du serveur au lieu du /1000 codé en dur, par appel http direct — le motif déjà établi dans cet écran pour knowledge et insights. ⚠️ Si l'appel échoue, la ligne « ~N questions » disparaît plutôt que d'afficher un repli : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux — ce que le /1000 faisait depuis le début, à un facteur 10 près. flutter analyze lib sans erreur, flutter build web ✅.
+
+
+
+ Lot F backend — portail de facturation Stripe et coût réel d'une question
+ Customer Portal : StripeService.CreateBillingPortalSessionAsync + POST /api/onboarding/billing-portal, calqué sur checkout-session. ⚠️ Une garde absente de l'énoncé : StripeCustomerId peut être nul. CreateCustomerAsync n'est appelée que par l'inscription self-service, donc les 4 instances de la bascule, venues de Mongo, n'ont jamais vu Stripe — sans la garde, elles partaient chercher le portail d'un client inexistant et récupéraient une erreur d'API Stripe illisible côté front. C'est un 409, distinct du 404 d'instance inconnue.
+ Ratio jetons → questions : InstanceQuotaDTO.aiTokensPerQuestion est désormais mesuré sur les VisitorQuestion.TokensUsed de l'instance, avec un seuil de 20 questions avant de faire foi — une seule réponse citant un long article doublerait la moyenne, et le crédit restant afficherait moitié moins d'un rafraîchissement à l'autre. ⚠️ Le /1000 de manager-app était faux d'un facteur 10, et c'est un chiffre montré au client : la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit 10 000 par question. Le gestionnaire lisait un crédit dix fois trop généreux. Le repli serveur est calé sur 10 000. Reste à brancher le diviseur dans manager-app.
+ ⛔ Trois points du lot F étaient déjà faits et n'attendaient qu'une vérification : le rate limiting (livré, pas seulement décidé), l'endpoint de mise à jour d'ApplicationInstance (CRUD complet existant — ce qui manque est l'écran SuperAdmin, déjà parqué en V2) et les quotas seed (alignés par AlignSubscriptionPlansWithPricing). 6 tests ajoutés, dotnet test 197 passés / 14 sautés / 0 échec.
+
+
C4 — compression des images à l'upload, 2560 px / JPEG q82
flutter build web vert. Un seul helper appelé par les deux chemins d'upload de resources_screen — le motif exact qui a fait diverger Create et Upload côté serveur, sur StoragePath en C1 puis sur le pré-vol de quota en C3. ⚠️ La compression se fait avant resourceCreate, pas avant l'upload : le quota de C3 porte sur sizeBytes à la création, donc compresser après aurait fait décider le serveur sur la taille d'origine — et un quota qui compte 12× trop se remplit 12× trop vite.
@@ -1187,7 +1221,7 @@
// « Fait récemment » n'a pas d'horizon : c'est du passé, pas du reste-à-faire.
if (done) { done.classList.toggle('hidden', horizon !== 'all'); }
- if (stats[5]) { stats[5].classList.toggle('hidden', horizon !== 'all'); }
+ if (stats[6]) { stats[6].classList.toggle('hidden', horizon !== 'all'); }
if (emptyMsg) { emptyMsg.classList.toggle('hidden', total !== 0); }
}
diff --git a/v1-plan.md b/v1-plan.md
index 7e8d43d..e8905af 100644
--- a/v1-plan.md
+++ b/v1-plan.md
@@ -174,6 +174,21 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales,
🔨 **Volet `manager-service` livré le 2026-08-12** — les quatre chantiers de dette backend sont fermés en une passe : journalisation des sections, plafond de 5 utilisateurs, `GetSummary` en SQL, tests du vector store. `dotnet test` **163 → 200**. Restent l'aperçu de conversation (L9), l'onglet « Vocal », les restes non chiffrés, le rate limiting et le Customer Portal.
+✅ **Volet `manager-service` terminé le 2026-08-13.** Customer Portal livré, ratio jetons → questions calé sur du réel ; le rate limiting, l'endpoint `ApplicationInstance` et les quotas seed **étaient déjà faits** et n'attendaient qu'une vérification (détail dans les lignes concernées). `dotnet build` vert, `dotnet test` **211 au total : 197 passés, 14 sautés** (Testcontainers, pas de démon Docker), 0 échec.
+
+**Ce qui reste au lot F, et dans quel repo** — le backend ne bloque plus rien :
+- ~~`manager-app`~~ ✅ **livré le 2026-08-13** : bouton du Customer Portal et diviseur `aiTokensPerQuestion`. `flutter analyze lib` **sans erreur**, `flutter build web` ✅. Détail ci-dessous.
+- `mymuseum-visitapp` : déclenchement proactif (+ M3), miroir de la conversation vocale, émission des événements vocaux. C'est désormais **tout le reste du lot F**, et le plus gros.
+- Non commencé, indépendant : l'aperçu de conversation (**L9**).
+
+**Volet `manager-app` du 2026-08-13 — deux décisions prises en écrivant, à ne pas re-trancher :**
+
+⚠️ **Le bouton du portail ne remplace pas celui du Checkout, il prend sa place quand l'essai est fini.** L'écran n'affichait rien du tout hors essai (`if (isTrialActive)` enveloppait le seul bouton) : un client payant arrivait sur un écran sans action. C'est maintenant Checkout **pendant** l'essai, portail **après** — les deux ne coexistent jamais, souscrire deux fois n'a pas de sens.
+
+⚠️ **Le 409 a son propre message, et c'est le point qui se serait vu en prod.** Les 4 instances de la bascule viennent de Mongo et n'ont pas de `StripeCustomerId` : leur montrer « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. Le message dit que leur facturation passe directement par nous. **4 clés i18n FR/EN/NL.**
+
+⚠️ **Le diviseur vient du serveur, et son absence masque la ligne au lieu de mentir.** `guide_ia_screen` appelle `GET /api/Instance/{id}/quota` par `http` direct — le motif déjà établi dans cet écran pour `knowledge` et `insights`, un agrégat en lecture seule ne justifiant pas d'étendre `manager_api_new`. Si l'appel échoue, `~N questions` **disparaît** : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux — c'est exactement ce que le `/1000` faisait depuis le début.
+
| Quoi | Lien |
|---|---|
| ~~Volet RGPD~~ | **Déplacé au lot J**, fin de backlog (décidé le 2026-08-11) |
@@ -191,9 +206,9 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales,
| ~~**`WeatherSyncService` inondait le journal**~~ | ✅ **Corrigé le 2026-08-12 — régression du correctif ci-dessus, trouvée en creusant « pourquoi un index ? ».** `WeatherSyncService` écrit `section.WeatherResult` sur cron, à 6 h et à 13 h. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la **prévision OpenWeather complète en avant ET en après** — quelques dizaines de Ko, deux fois par jour, par section météo. Le stockage est le moindre problème : ce bruit **noie les modifications humaines** que l'écran existe pour montrer. Correctif : une liste de colonnes machine (`WeatherResult`, `WeatherUpdatedDate`, `DateUpdate`) exclues du journal, et **aucune ligne produite** quand une modification ne touche qu'elles. `DateUpdate` y est pour une raison distincte — estampillé à chaque `SaveChanges`, il figurait dans tous les diffs sans rien y apprendre ; le signal était là, un test devait l'écarter à la main pour rester lisible. Vérifié : `AgendaSyncService` écrit des `EventAgenda`, non audités, et ne touche pas la ligne `Section` |
| ~~`AuditLog` n'a pas d'index sur `Timestamp`~~ | ⛔ **Fausse alerte, retirée le 2026-08-12.** Signalée par réflexe sur « la table va grossir » — elle allait grossir à cause de la météo, pas des humains. Le flot machine coupé, il reste les éditions réelles : de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients, qu'un `ORDER BY Timestamp DESC LIMIT 50` trie en millisecondes sans index. **Donc pas d'index, pas de migration, et le gel du lot B n'est pas remis en cause** |
| **La bascule écrira ~2 700 lignes d'audit dans la transaction globale** | ⚠️ **Vu le 2026-08-12.** 2 374 ressources + 307 sections + le reste, chacune avec un instantané JSON complet, dans la transaction unique posée par l'écart (h) du lot G. Pas fatal, mais c'est **neuf pour les sections** et c'est la transaction qui ne doit pas échouer. À surveiller au prochain dry run |
-| Restes non chiffrés : ratio jetons → questions (`/1000`) calé sur du réel, endpoint de mise à jour d'`ApplicationInstance` pour rendre les canaux activables, quotas seed 5M/20M ajustés | §1quater |
-| **Rate limiting sur les API Keys** | ✅ **Confirmé V1 le 2026-08-12.** `AddRateLimiter` (natif ASP.NET Core 8), politique partitionnée par `InstanceId` sur les routes IA. **La cible n'est pas l'abus générique mais le quota de jetons** : `/api/AI/chat` est le seul endpoint qui coûte de l'argent réel, et la clé qui y donne accès est publique par construction |
-| **Stripe Customer Portal** | ✅ **Confirmé V1 le 2026-08-12.** Endpoint créant une session de portail (`Stripe.BillingPortal`) + bouton dans l'écran `Screens/Billing/subscription_screen.dart`, **qui existe déjà** mais ne sait que lancer un Checkout de souscription. Stripe héberge la page, il n'y a pas d'UI de paiement à écrire |
+| ~~Restes non chiffrés~~ | ✅ **Traités le 2026-08-13 — et deux des trois étaient déjà faits.**
**Ratio jetons → questions** : livré côté serveur. `InstanceQuotaDTO.aiTokensPerQuestion` est désormais **mesuré** sur les `VisitorQuestion.TokensUsed` de l'instance, avec un seuil de 20 questions avant de faire foi — sans ce seuil, une seule réponse citant un long article doublerait la moyenne et le crédit restant affiché changerait de moitié d'un rafraîchissement à l'autre. En dessous du seuil, repli sur l'hypothèse de la grille tarifaire.
⚠️ **Le `/1000` de manager-app était faux d'un facteur 10, et c'est un chiffre montré au client.** `guide_ia_screen.dart:428` fait `(used / 1000)`, alors que la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit **10 000 jetons par question** (`MyInfoMateDbContext:619`). Le gestionnaire lisait donc un crédit restant **dix fois trop généreux**. Le repli du serveur est calé sur 10 000, pas sur 1 000. **Reste à faire dans `manager-app`** (session séparée) : diviser par `aiTokensPerQuestion` au lieu de la constante.
⛔ **Endpoint de mise à jour d'`ApplicationInstance` : il existe déjà.** `ApplicationInstanceController` porte un CRUD complet — `PUT /api/ApplicationInstance` (`:115`) et `POST` (`:75`) — et `InstanceController.Updateinstance` écrit déjà `IsMobile`/`IsTablet`/`IsWeb` (`:209-211`). Activer un canal est donc **déjà faisable par l'API** ; ce qui manque est l'écran SuperAdmin, explicitement parqué en V2 au §5. Rien à écrire ici.
⛔ **Quotas seed 5M/20M : déjà ajustés** par `AlignSubscriptionPlansWithPricing` — le seed dit Essentiel 0, Pro 0, Premium 20 M, Enterprise `long.MaxValue`. Le « 5M » de cette ligne datait d'avant l'alignement | §1quater |
+| ~~**Rate limiting sur les API Keys**~~ | ✅ **Livré — constaté le 2026-08-13**, la ligne du 12/08 ne notait qu'une décision. Tout est en place : politique `ai` partitionnée par `InstanceId` (`Startup.cs:279-299`), `app.UseRateLimiter()` (`:344`) et les **deux** endpoints décorés (`AiController:312/349`). Plafond 120/min, 429 + `Retry-After: 60`.
⚠️ **L'ordre du pipeline est un choix, pas un hasard** — un commentaire le dit dans `Startup.cs` : le limiteur est **après `UseCors`** (sinon un 429 sans en-têtes CORS s'affiche comme une erreur CORS dans le navigateur et `visitapp-web` ne voit jamais le vrai code) et **après `UseAuthentication`** (sinon la partition n'a pas encore le claim d'instance et tout le monde tombe dans le même seau). Ne pas réordonner |
+| ~~**Stripe Customer Portal**~~ | 🔨 **Backend livré le 2026-08-13.** `StripeService.CreateBillingPortalSessionAsync` + `POST /api/onboarding/billing-portal`, calqué sur `checkout-session` : Stripe héberge la page, on ne renvoie qu'une URL de redirection. **2 tests** (les deux gardes ; l'appel Stripe lui-même part sur le réseau et n'est pas couvert).
⚠️ **Une garde qui n'était pas dans l'énoncé : `StripeCustomerId` peut être nul.** Les 4 instances de la bascule viennent de Mongo et n'ont **jamais vu Stripe** — `CreateCustomerAsync` n'est appelée que par l'inscription self-service (`OnboardingController:224`). Sans la garde, elles seraient parties chercher un portail pour un client inexistant et auraient reçu une erreur d'API Stripe illisible côté front. C'est un **409**, distinct du 404 d'instance inconnue.
**Reste dans `manager-app`** (session séparée) : le bouton dans `Screens/Billing/subscription_screen.dart`, qui ne sait toujours que lancer un Checkout de souscription |
| ~~Tests du vector store via `Testcontainers.PostgreSql`~~ | ✅ **Livré le 2026-08-12 — et deux résultats contredisent ce que cette ligne supposait.** 14 tests sur l'image de `Deployment/Dockerfile.postgres`, donc le même PostGIS + pgvector que la prod ; ils se sautent proprement (`SkippableFact`) sans démon Docker — vérifié : 186 passés / 14 sautés / 0 échec.
**Établi** : pgvector **0.8.6**, `SET hnsw.iterative_scan` est accepté — ce SET est posé à chaque recherche par `SearchAsync` et n'existe qu'à partir de 0.8, donc sur une version antérieure **toute recherche échouait en production** ; les extensions `vector` et `postgis` sont bien créées par les migrations ; et à deux instances, l'une saturant l'axe de la question à **30 contre 1**, `SearchAsync` rend le bon nombre de résultats, tous de la bonne instance, **aucune fuite d'un client vers un autre**.
⚠️ **1. Ce qui protège du post-filtrage n'est PAS le parcours itératif, c'est l'index sur `(InstanceId, ContentType)`.** Le planificateur filtre par instance d'abord et trie exactement : l'index HNSW n'est jamais touché, donc il n'y a rien à post-filtrer. Vérifié à 620 lignes (test) et à **22 000** hors suite, avec 2 000 lignes pour l'instance cible. Un test fige ce plan — si cet index disparaissait, la recherche se dégraderait **sans qu'aucune erreur ne le dise**.
⚠️ **2. Et si on retire cet index, `hnsw.iterative_scan = relaxed_order` ne rattrape rien** : le parcours s'épuise après ~335 lignes (`Rows Removed by Filter` à l'`EXPLAIN ANALYZE`) sans atteindre l'instance minoritaire, et rend **zéro**. Le même jeu de données avec l'index HNSW construit **après** l'insertion rend bien ses 20 lignes : c'est la **connectivité du graphe** qui décide, et nos migrations créent l'index sur une table vide. Le SET de `SearchAsync` est correct et sans coût — on le garde — mais **il ne couvre pas ce qu'on croyait**.
**Outillage** : `Testcontainers.PostgreSql` épinglé en **3.10.0** (la 4.x parle l'API Docker 1.44, l'engine local plafonne à 1.43) et l'image construite par le **CLI docker**, pas par le constructeur d'images de Testcontainers — il relit le `FROM` pour pré-tirer l'image de base et ne sait pas parser `tag@sha256:`. Ce digest protège la base d'un changement de glibc sous ses index : il ne se retire pas pour arranger un test | **L14** |
### Lot G — `MigrationController` à niveau + dry run (3 à 4 jours) — le plus gros
@@ -361,7 +376,7 @@ Les CGU §8 ont été réécrites le 2026-08-11 (`cgu-myinfomate.md`) et le text
> ⚠️ **À arbitrer avant le lot H** : Pro n'inclut pas l'IA, donc rien ne s'indexera pour MDLF et le Fort et leur guide ne répondra jamais. Sur 4 instances, la chaîne RAG ne serait éprouvée que sur 2 — or le lien **L14** demande justement de tester le post-filtrage HNSW **à deux instances au moins**. Deux sorties : les passer en Premium le temps des tests, ou leur poser un quota IA à la main (l'add-on ci-dessus), en pensant au rattrapage d'indexation.
-**4. Écart restant, hors bascule** : la FAQ de `myinfomate-landing` (`src/data/segments.ts`) appelle encore le plan à 179€ « **Bundle** » — **41 occurrences en 4 langues** — alors que les cartes de tarifs disent « Premium ». Un prospect qui lit la grille puis la FAQ voit deux noms pour la même offre. Chantier de texte commercial, sans effet sur la migration.
+**4. ~~Écart restant, hors bascule~~** ✅ **Corrigé le 2026-08-13.** La FAQ de `myinfomate-landing` (`src/data/segments.ts`) appelait le plan à 179 € « **Bundle** » alors que les cartes de tarifs disent « Premium » : **41 occurrences en 4 langues** (FR, EN, NL, DE) passées en « Premium ». Vérifié : plus aucun « Bundle » dans `myinfomate-landing/src`, et les formes composées des langues germaniques suivent (`Bundle-abonnementen` → `Premium-abonnementen`, `Bundle-Pläne` → `Premium-Pläne`). `npm run build` vert. Aucun identifiant touché — les 41 occurrences étaient toutes de la prose.
| I7 | **Recette de bascule — [test-plan.md §22](test-plan.md)**, ajoutée le 2026-08-11. Comptages globaux, puis **par type de section**, collections filles, colonnes générées, et comparaison à l'écran ancienne app vs nouvelle. ⚠️ **À jouer pendant que Mongo est encore lisible** : c'est la seule fenêtre où les deux bases coexistent, après la comparaison est impossible | Le dry run compare des **comptages**, le §22 compare le **contenu** — un compte juste ne dit pas qu'un quiz a gardé ses questions ni qu'un article a gardé ses traductions. Les écarts b et c ne se voient qu'ici |
| I8 | Bascule DNS / API, Mongo gardé en lecture seule quelques jours, `pg_dump` après | ⚠️ **Remplacé par la stratégie de coexistence ci-dessous, décidée le 2026-08-12** |