Lot F : bouton du portail Stripe et diviseur de questions mesuré

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-13 10:11:06 +02:00
parent 3429153135
commit cf60c098c4
10 changed files with 204 additions and 15 deletions

View File

@ -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<SubscriptionScreen> {
}
}
/// 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<void> _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<SubscriptionScreen> {
],
),
)),
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)),

View File

@ -43,6 +43,9 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
Map<String, dynamic>? _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<GuideIaScreen> {
});
_loadKnowledge();
_loadInsights();
_loadTokensPerQuestion();
} catch (_) {
if (mounted) setState(() => _loading = false);
}
@ -138,6 +142,30 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
}
}
/// 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<void> _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<String, dynamic>;
final value = (decoded['aiTokensPerQuestion'] as num?)?.toInt() ?? 0;
if (value > 0) setState(() => _tokensPerQuestion = value);
} catch (_) {
// La ligne « ~N questions » reste masquée.
}
}
Future<void> _save() async {
final instance = _instance;
if (instance == null) return;
@ -424,8 +452,11 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
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<GuideIaScreen> {
),
),
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)),
],

View File

@ -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.",

View File

@ -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.",

View File

@ -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:

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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.",

View File

@ -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<String, dynamic>;
return decoded['url'] as String;
}
/// Performs an HTTP 'POST /api/onboarding/billing-portal' operation and returns the [Response].
Future<Response> onboardingCreateBillingPortalSessionWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/api/onboarding/billing-portal';
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['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<String> 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<String, dynamic>;
return decoded['url'] as String;
}
}