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:
parent
3429153135
commit
cf60c098c4
@ -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)),
|
||||
|
||||
@ -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)),
|
||||
],
|
||||
|
||||
@ -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.",
|
||||
|
||||
|
||||
@ -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.",
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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.",
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user