From cf60c098c43f5850444b84ace21c731c365847ed Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 13 Aug 2026 10:11:06 +0200 Subject: [PATCH] =?UTF-8?q?Lot=20F=20:=20bouton=20du=20portail=20Stripe=20?= =?UTF-8?q?et=20diviseur=20de=20questions=20mesur=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Désormais Checkout pendant l'essai, portail après ; les deux ne coexistent jamais. Le 409 a son propre message : les instances venues de Mongo n'ont pas de StripeCustomerId, « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. 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. manager_api_new étendu à la main, générateur non relancé. 4 clés i18n FR/EN/NL. flutter analyze lib sans erreur, flutter build web OK. Co-Authored-By: Claude Opus 5 --- lib/Screens/Billing/subscription_screen.dart | 60 ++++++++++++++++---- lib/Screens/GuideIa/guide_ia_screen.dart | 38 ++++++++++++- lib/l10n/app_en.arb | 4 ++ lib/l10n/app_fr.arb | 4 ++ lib/l10n/app_localizations.dart | 24 ++++++++ lib/l10n/app_localizations_en.dart | 15 +++++ lib/l10n/app_localizations_fr.dart | 15 +++++ lib/l10n/app_localizations_nl.dart | 15 +++++ lib/l10n/app_nl.arb | 4 ++ manager_api_new/lib/api/onboarding_api.dart | 40 ++++++++++++- 10 files changed, 204 insertions(+), 15 deletions(-) diff --git a/lib/Screens/Billing/subscription_screen.dart b/lib/Screens/Billing/subscription_screen.dart index 6bf6350..25442da 100644 --- a/lib/Screens/Billing/subscription_screen.dart +++ b/lib/Screens/Billing/subscription_screen.dart @@ -1,6 +1,7 @@ import 'dart:html' as html; import 'package:flutter/material.dart'; +import 'package:manager_api_new/api.dart'; import 'package:manager_app/Components/common_loader.dart'; import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/Components/rounded_button.dart'; @@ -46,6 +47,31 @@ class _SubscriptionScreenState extends State { } } + /// Portail Stripe : changer de carte, relire ses factures, résilier. Stripe héberge la page. + /// + /// ⚠️ Le 409 n'est pas une erreur à afficher comme telle : il dit que l'instance n'a pas de + /// client Stripe — le cas de **tous les clients venus de la migration Mongo**, qui n'ont + /// jamais souscrit en ligne. Leur montrer « réessayez plus tard » les ferait attendre + /// quelque chose qui n'arrivera pas ; le message dit que leur facturation passe par nous. + Future _openBillingPortal(ManagerAppContext ctx) async { + setState(() => isRedirecting = true); + try { + final url = await ctx.clientAPI!.onboardingApi!.onboardingCreateBillingPortalSession(); + html.window.open(url, '_blank'); + } on ApiException catch (e) { + if (!mounted) return; + final l = AppLocalizations.of(context)!; + final message = e.code == 409 ? l.subscriptionPortalNoCustomer : l.subscriptionPortalError; + showNotification(kError, kWhite, message, context, null); + } catch (e) { + if (mounted) { + showNotification(kError, kWhite, AppLocalizations.of(context)!.subscriptionPortalError, context, null); + } + } finally { + if (mounted) setState(() => isRedirecting = false); + } + } + String _formatDate(DateTime date) { final d = date.toLocal(); return "${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}"; @@ -121,17 +147,29 @@ class _SubscriptionScreenState extends State { ], ), )), - if (isTrialActive) ...[ - const SizedBox(height: 16), - isRedirecting - ? const CommonLoader(iconSize: 32) - : RoundedButton( - text: l.subscriptionUpgradeBtn, - fontSize: 15, - vertical: 14, - horizontal: 24, - press: () => _startCheckout(managerCtx), - ), + const SizedBox(height: 16), + if (isRedirecting) + const CommonLoader(iconSize: 32) + else if (isTrialActive) + RoundedButton( + text: l.subscriptionUpgradeBtn, + fontSize: 15, + vertical: 14, + horizontal: 24, + press: () => _startCheckout(managerCtx), + ) + else ...[ + // Hors essai, l'abonnement est en cours : ce qu'on peut encore vouloir faire ici, + // c'est corriger une carte ou relire une facture — pas souscrire une seconde fois. + Text(l.subscriptionManageDesc, style: TextStyle(fontSize: 13, color: Colors.grey[700])), + const SizedBox(height: 12), + RoundedButton( + text: l.subscriptionManageBtn, + fontSize: 15, + vertical: 14, + horizontal: 24, + press: () => _openBillingPortal(managerCtx), + ), ], const SizedBox(height: 32), Text(l.subscriptionAddonsTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), diff --git a/lib/Screens/GuideIa/guide_ia_screen.dart b/lib/Screens/GuideIa/guide_ia_screen.dart index 034ecc4..172b539 100644 --- a/lib/Screens/GuideIa/guide_ia_screen.dart +++ b/lib/Screens/GuideIa/guide_ia_screen.dart @@ -43,6 +43,9 @@ class _GuideIaScreenState extends State { Map? _knowledge; + /// Nul tant que le serveur n'a pas répondu : la ligne « ~N questions » ne s'affiche pas. + int? _tokensPerQuestion; + @override void initState() { super.initState(); @@ -98,6 +101,7 @@ class _GuideIaScreenState extends State { }); _loadKnowledge(); _loadInsights(); + _loadTokensPerQuestion(); } catch (_) { if (mounted) setState(() => _loading = false); } @@ -138,6 +142,30 @@ class _GuideIaScreenState extends State { } } + /// Même appel direct que les deux ci-dessus. On ne lit qu'une chose : combien de jetons + /// coûte une question **chez ce client**, mesuré par le serveur sur ses propres questions. + /// + /// ⚠️ Un échec laisse `_tokensPerQuestion` nul et la ligne « ~N questions » disparaît. + /// C'est délibéré : le diviseur codé en dur qui vivait ici valait 1 000, alors que la grille + /// vend 20 M de jetons pour ~2 000 questions — soit 10 000. Le gestionnaire lisait un crédit + /// **dix fois trop généreux**. Pas de chiffre vaut mieux qu'un chiffre faux sur une jauge + /// qui sert à décider d'un achat. + Future _loadTokensPerQuestion() async { + final ctx = _managerContext; + try { + final response = await http.get( + Uri.parse('${ctx.host}/api/Instance/${ctx.instanceId}/quota'), + headers: {'Authorization': 'Bearer ${ctx.accessToken}'}, + ); + if (!mounted || response.statusCode != 200) return; + final decoded = jsonDecode(response.body) as Map; + final value = (decoded['aiTokensPerQuestion'] as num?)?.toInt() ?? 0; + if (value > 0) setState(() => _tokensPerQuestion = value); + } catch (_) { + // La ligne « ~N questions » reste masquée. + } + } + Future _save() async { final instance = _instance; if (instance == null) return; @@ -424,8 +452,11 @@ class _GuideIaScreenState extends State { Widget _usageCard(AppLocalizations l) { final used = _instance?.aiTokensThisMonth ?? 0; final quota = _instance?.aiTokensPerMonth ?? 0; - // Le gestionnaire raisonne en questions, pas en jetons de traitement. - final questions = (used / 1000).round(); + // Le gestionnaire raisonne en questions, pas en jetons de traitement. Le diviseur vient + // du serveur (`aiTokensPerQuestion`), qui le mesure sur les questions réellement posées + // par cette instance — il n'est pas codé ici. + final tokensPerQuestion = _tokensPerQuestion; + final questions = tokensPerQuestion != null ? (used / tokensPerQuestion).round() : null; final ratio = quota > 0 ? (used / quota).clamp(0.0, 1.0) : 0.0; return _card( @@ -447,7 +478,8 @@ class _GuideIaScreenState extends State { ), ), const SizedBox(height: 10), - Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)), + if (questions != null) + Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)), ] else Text(l.guideIaUsageNoQuota, style: TextStyle(fontSize: 13, color: kBodyTextColor)), ], diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 182bf84..ea15ee3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -45,6 +45,10 @@ "subscriptionIncludedTitle": "What's included", "subscriptionUpgradeBtn": "Upgrade to a paid subscription", "subscriptionCheckoutError": "Could not start checkout, please try again later.", + "subscriptionManageBtn": "Manage my subscription", + "subscriptionManageDesc": "Update your card, view invoices or cancel — on Stripe's secure pages.", + "subscriptionPortalError": "Could not open the billing portal, please try again later.", + "subscriptionPortalNoCustomer": "This instance has no online subscription. Your billing is handled directly with us.", "subscriptionAddonsTitle": "Add-ons", "subscriptionAddonsComingSoon": "Coming soon: extra AI requests and other options.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9043128..3721283 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -45,6 +45,10 @@ "subscriptionIncludedTitle": "Ce qui est inclus", "subscriptionUpgradeBtn": "Passer à un abonnement payant", "subscriptionCheckoutError": "Impossible de démarrer le paiement, réessayez plus tard.", + "subscriptionManageBtn": "Gérer mon abonnement", + "subscriptionManageDesc": "Changer de carte, consulter vos factures ou résilier — sur les pages sécurisées de Stripe.", + "subscriptionPortalError": "Impossible d'ouvrir le portail de facturation, réessayez plus tard.", + "subscriptionPortalNoCustomer": "Aucun abonnement en ligne n'est rattaché à cette instance. Votre facturation est gérée directement avec nous.", "subscriptionAddonsTitle": "Add-ons", "subscriptionAddonsComingSoon": "Bientôt disponible : requêtes IA supplémentaires et autres options.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8a333c9..58a9c47 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -346,6 +346,30 @@ abstract class AppLocalizations { /// **'Impossible de démarrer le paiement, réessayez plus tard.'** String get subscriptionCheckoutError; + /// No description provided for @subscriptionManageBtn. + /// + /// In fr, this message translates to: + /// **'Gérer mon abonnement'** + String get subscriptionManageBtn; + + /// No description provided for @subscriptionManageDesc. + /// + /// In fr, this message translates to: + /// **'Changer de carte, consulter vos factures ou résilier — sur les pages sécurisées de Stripe.'** + String get subscriptionManageDesc; + + /// No description provided for @subscriptionPortalError. + /// + /// In fr, this message translates to: + /// **'Impossible d\'ouvrir le portail de facturation, réessayez plus tard.'** + String get subscriptionPortalError; + + /// No description provided for @subscriptionPortalNoCustomer. + /// + /// In fr, this message translates to: + /// **'Aucun abonnement en ligne n\'est rattaché à cette instance. Votre facturation est gérée directement avec nous.'** + String get subscriptionPortalNoCustomer; + /// No description provided for @subscriptionAddonsTitle. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8d9fec3..6625886 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -138,6 +138,21 @@ class AppLocalizationsEn extends AppLocalizations { String get subscriptionCheckoutError => 'Could not start checkout, please try again later.'; + @override + String get subscriptionManageBtn => 'Manage my subscription'; + + @override + String get subscriptionManageDesc => + 'Update your card, view invoices or cancel — on Stripe\'s secure pages.'; + + @override + String get subscriptionPortalError => + 'Could not open the billing portal, please try again later.'; + + @override + String get subscriptionPortalNoCustomer => + 'This instance has no online subscription. Your billing is handled directly with us.'; + @override String get subscriptionAddonsTitle => 'Add-ons'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 450f2c8..902e518 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -141,6 +141,21 @@ class AppLocalizationsFr extends AppLocalizations { String get subscriptionCheckoutError => 'Impossible de démarrer le paiement, réessayez plus tard.'; + @override + String get subscriptionManageBtn => 'Gérer mon abonnement'; + + @override + String get subscriptionManageDesc => + 'Changer de carte, consulter vos factures ou résilier — sur les pages sécurisées de Stripe.'; + + @override + String get subscriptionPortalError => + 'Impossible d\'ouvrir le portail de facturation, réessayez plus tard.'; + + @override + String get subscriptionPortalNoCustomer => + 'Aucun abonnement en ligne n\'est rattaché à cette instance. Votre facturation est gérée directement avec nous.'; + @override String get subscriptionAddonsTitle => 'Add-ons'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 79a7880..c2f37e6 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -141,6 +141,21 @@ class AppLocalizationsNl extends AppLocalizations { String get subscriptionCheckoutError => 'Kon de betaling niet starten, probeer het later opnieuw.'; + @override + String get subscriptionManageBtn => 'Mijn abonnement beheren'; + + @override + String get subscriptionManageDesc => + 'Kaart wijzigen, facturen bekijken of opzeggen — op de beveiligde pagina\'s van Stripe.'; + + @override + String get subscriptionPortalError => + 'Kon het factuurportaal niet openen, probeer het later opnieuw.'; + + @override + String get subscriptionPortalNoCustomer => + 'Deze instantie heeft geen online abonnement. Uw facturatie verloopt rechtstreeks via ons.'; + @override String get subscriptionAddonsTitle => 'Add-ons'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index f518fd5..1c06167 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -45,6 +45,10 @@ "subscriptionIncludedTitle": "Wat is inbegrepen", "subscriptionUpgradeBtn": "Overstappen naar een betaald abonnement", "subscriptionCheckoutError": "Kon de betaling niet starten, probeer het later opnieuw.", + "subscriptionManageBtn": "Mijn abonnement beheren", + "subscriptionManageDesc": "Kaart wijzigen, facturen bekijken of opzeggen — op de beveiligde pagina's van Stripe.", + "subscriptionPortalError": "Kon het factuurportaal niet openen, probeer het later opnieuw.", + "subscriptionPortalNoCustomer": "Deze instantie heeft geen online abonnement. Uw facturatie verloopt rechtstreeks via ons.", "subscriptionAddonsTitle": "Add-ons", "subscriptionAddonsComingSoon": "Binnenkort beschikbaar: extra AI-verzoeken en andere opties.", diff --git a/manager_api_new/lib/api/onboarding_api.dart b/manager_api_new/lib/api/onboarding_api.dart index 2bca9cf..cb11ba3 100644 --- a/manager_api_new/lib/api/onboarding_api.dart +++ b/manager_api_new/lib/api/onboarding_api.dart @@ -1,5 +1,6 @@ // -// Manually added (not generated) — mirrors OnboardingController.CreateCheckoutSession. +// Manually added (not generated) — mirrors OnboardingController.CreateCheckoutSession +// and CreateBillingPortalSession. // Do not run the OpenAPI generator on this file, it would be overwritten. // // @dart=2.18 @@ -43,4 +44,41 @@ class OnboardingApi { final decoded = jsonDecode(body) as Map; return decoded['url'] as String; } + + /// Performs an HTTP 'POST /api/onboarding/billing-portal' operation and returns the [Response]. + Future onboardingCreateBillingPortalSessionWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/api/onboarding/billing-portal'; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + null, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Returns the Stripe Billing Portal URL to redirect the user to. + /// + /// Throws an [ApiException] with status 409 when the instance has no Stripe customer — + /// the case of every instance that came from the Mongo migration rather than from + /// self-service sign-up. The caller must tell those two apart. + Future onboardingCreateBillingPortalSession() async { + final response = await onboardingCreateBillingPortalSessionWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + final body = await _decodeBodyBytes(response); + final decoded = jsonDecode(body) as Map; + return decoded['url'] as String; + } }