From 0db8c1a4ab7636fdf74f34f877b8b8f96911c995 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Fri, 31 Jul 2026 15:31:48 +0530 Subject: [PATCH 1/3] Update the Stripe subscription flow for Android. --- lantern-core/core.go | 2 +- lantern-core/mobile/mobile.go | 8 + lantern-core/utils/common.go | 7 + lib/core/common/app_secrets.dart | 6 - lib/core/extensions/plan.dart | 6 + lib/core/models/plan_data.dart | 61 +++++-- lib/core/services/injection_container.dart | 23 ++- lib/core/services/stripe_service.dart | 156 ++++++++++++------ lib/features/auth/choose_payment_method.dart | 81 ++++----- .../plans/provider/plans_notifier.dart | 13 ++ 10 files changed, 233 insertions(+), 130 deletions(-) diff --git a/lantern-core/core.go b/lantern-core/core.go index e5d3123144..7438c65e6d 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -223,7 +223,7 @@ func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEv } slog.Debug("Starting LanternCore initialization") - if opts.Env == "stage" || opts.Env == "staging" { + if opts.IsStaging() { slog.Debug("Setting staging environment") env.SetStagingEnv() } diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index bab8a15c02..d34a8d3c9e 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -16,6 +16,7 @@ import ( "github.com/getlantern/radiance/account" "github.com/getlantern/radiance/backend" "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/common/settings" "github.com/getlantern/radiance/ipc" @@ -288,6 +289,13 @@ func StartIPCServer(platform utils.PlatformInterface, opts *utils.Opts) error { if ipcServer != nil { return struct{}{}, nil } + // The backend's config fetcher captures common.GetBaseURL() at + // construction, so the environment must be set before + // NewLocalBackend — SetupRadiance's SetStagingEnv runs too late on + // Android, where StartIPCServer is called first. + if opts.IsStaging() { + env.SetStagingEnv() + } bopts := backend.Options{ DataDir: opts.DataDir, LogDir: opts.LogDir, diff --git a/lantern-core/utils/common.go b/lantern-core/utils/common.go index fc4cd3db45..38a8623e80 100644 --- a/lantern-core/utils/common.go +++ b/lantern-core/utils/common.go @@ -15,6 +15,13 @@ type Opts struct { Platform PlatformInterface } +// IsStaging reports whether Env selects the staging environment. Both +// spellings are accepted; keep every env check on this method so the +// vocabulary can't drift between call sites. +func (o *Opts) IsStaging() bool { + return o.Env == "stage" || o.Env == "staging" +} + type PrivateServerEventListener interface { OpenBrowser(url string) error OnPrivateServerEvent(event string) diff --git a/lib/core/common/app_secrets.dart b/lib/core/common/app_secrets.dart index 14c7468088..b53a1b8059 100644 --- a/lib/core/common/app_secrets.dart +++ b/lib/core/common/app_secrets.dart @@ -3,12 +3,6 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; class AppSecrets { static String get macosAppGroupId => dotenv.env['MACOS_APP_GROUP'] ?? ''; - static String get stripeTestPublishableKey => - dotenv.env['STRIPE_TEST_PUBLISHABLE_KEY'] ?? ''; - - static String get stripePublishableKey => - dotenv.env['STRIPE_PUBLISHABLE_KEY'] ?? ''; - static String get windowsAppUserModelId => dotenv.env['WINDOWS_APP_USER_MODEL_ID'] ?? ''; diff --git a/lib/core/extensions/plan.dart b/lib/core/extensions/plan.dart index f56ba152fc..18268b2664 100644 --- a/lib/core/extensions/plan.dart +++ b/lib/core/extensions/plan.dart @@ -12,6 +12,12 @@ extension PlanExtension on Plan { String get formattedMonthlyPrice => _formatPriceMap(expectedMonthlyPrice); + /// The expected monthly price in cents — the amount quoted to Stripe. + /// The backend always sends USD as the expectedMonthlyPrice currency. + int get monthlyUsdCents => expectedMonthlyPrice.isEmpty + ? 0 + : _amountOf(expectedMonthlyPrice).round(); + /// The original (pre-discount) yearly price, taken directly from the /// backend's `originalPrice` (no calculation). Shown as the strikethrough /// price next to the discounted price when an affiliate code is applied. diff --git a/lib/core/models/plan_data.dart b/lib/core/models/plan_data.dart index 2ee56847e2..f4512b7123 100644 --- a/lib/core/models/plan_data.dart +++ b/lib/core/models/plan_data.dart @@ -6,6 +6,10 @@ class PlansData { PlansData({required this.providers, required this.plans}); + /// The payment methods offered on the current platform. + List get platformProviders => + PlatformUtils.isMobile ? providers.android : providers.desktop; + /// Sorts plans (best-value first, then by descending price) and orders the /// platform's payment providers so subscription-capable ones come first. /// Applied after fetching/attaching plans (including referral V2). @@ -18,14 +22,22 @@ class PlansData { return b.usdPrice.compareTo(a.usdPrice); }); - int bySubscription(Android a, Android b) => - (b.providers.supportSubscription ? 1 : 0) - - (a.providers.supportSubscription ? 1 : 0); - if (PlatformUtils.isMobile) { - providers.android.sort(bySubscription); - } else { - providers.desktop.sort(bySubscription); + platformProviders.sort( + (a, b) => + (b.providers.supportSubscription ? 1 : 0) - + (a.providers.supportSubscription ? 1 : 0), + ); + } + + /// Publishable key advertised by the Stripe provider for this platform, + /// or null when Stripe isn't offered or no key was sent. + String? get stripePubKey { + for (final method in platformProviders) { + if (method.providers.name == 'stripe') { + return method.providers.data.pubKey; + } } + return null; } factory PlansData.fromJson(Map json) => PlansData( @@ -153,7 +165,7 @@ class Android { class Provider { String name; - Map? data; + ProviderData data; List icons; bool supportSubscription; @@ -161,24 +173,41 @@ class Provider { required this.name, required this.icons, required this.supportSubscription, - this.data, - }); + ProviderData? data, + }) : data = data ?? ProviderData(); factory Provider.fromJson(Map json) => Provider( name: json["name"], - data: json["data"] == null - ? {} - : (json["data"] as Map).map( - (key, value) => MapEntry(key, value), - ), + data: ProviderData.fromJson(json["data"]), icons: List.from(json["icons"].map((x) => x)), supportSubscription: json["supportsSubscription"] ?? false, ); Map toJson() => { "name": name, - "data": data, + "data": data.toJson(), "icons": List.from(icons.map((x) => x)), "supportsSubscription": supportSubscription, }; } + +/// Provider-specific payload. Its keys vary by provider (Stripe sends +/// `pubKey`, shepherd sends nothing), so the raw map is kept alongside the +/// typed accessors for keys the app understands. +class ProviderData { + final Map raw; + + ProviderData({this.raw = const {}}); + + /// Stripe publishable key for this provider, or null if absent/empty. + String? get pubKey { + final value = raw['pubKey']; + return value is String && value.isNotEmpty ? value : null; + } + + factory ProviderData.fromJson(dynamic json) => ProviderData( + raw: json is Map ? Map.from(json) : const {}, + ); + + Map toJson() => raw; +} diff --git a/lib/core/services/injection_container.dart b/lib/core/services/injection_container.dart index b93d720402..4c8631261d 100644 --- a/lib/core/services/injection_container.dart +++ b/lib/core/services/injection_container.dart @@ -75,22 +75,19 @@ Future injectServices() async { return service; }); - appLogger.debug('Initializing notification/Stripe services...'); + if (PlatformUtils.isAndroid) { + // The publishable key arrives with the plans response + // (PlansNotifier._syncStripeKey), so no upfront initialization. + sl.registerSingleton(StripeService()); + appLogger.debug('StripeService registered'); + } + + appLogger.debug('Initializing notification service...'); final notificationService = NotificationService(); try { - if (PlatformUtils.isAndroid) { - final stripeService = StripeService(); - await Future.wait([ - notificationService.init(), - stripeService.initialize(), - ]); - sl.registerSingleton(stripeService); - appLogger.debug('StripeService initialized'); - } else { - await notificationService.init(); - } + await notificationService.init(); } catch (e, st) { - appLogger.error('Notification/Stripe init failed', e, st); + appLogger.error('Notification init failed', e, st); } sl.registerSingleton(notificationService); appLogger.debug('NotificationService initialized'); diff --git a/lib/core/services/stripe_service.dart b/lib/core/services/stripe_service.dart index b03e6f4fde..90b52e49b3 100644 --- a/lib/core/services/stripe_service.dart +++ b/lib/core/services/stripe_service.dart @@ -1,39 +1,42 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; -import 'package:lantern/core/common/app_secrets.dart'; import 'package:lantern/core/common/common.dart'; class StripeService { - Future initialize() async { - try { - final String publishableKey; - if (kDebugMode) { - publishableKey = AppSecrets.stripeTestPublishableKey; - appLogger.info('Found debug mode using test stripe key'); - } else { - publishableKey = AppSecrets.stripePublishableKey; - if (publishableKey.isEmpty) { - throw StateError('Missing STRIPE_PUBLISHABLE_KEY'); - } - } - Stripe.publishableKey = publishableKey; - await Stripe.instance.applySettings(); - } catch (e, st) { - appLogger.error('Error initializing Stripe', e, st); - } + /// Adopts the publishable key advertised by the plans response so the SDK + /// always confirms intents against the same Stripe account/environment the + /// backend creates them in (prod → live key, staging → test key). The SDK + /// no-ops on an unchanged key and pushes a new one to the platform lazily + /// on the next native call. + void updatePublishableKey(String? pubKey) { + if (pubKey == null || pubKey.isEmpty) return; + Stripe.publishableKey = pubKey; } - // This method is used to start a Stripe subscription - // It takes the StripeOptions object and a callback function for success and error handling - // this is only used by android + /// Presents the payment sheet using Stripe's deferred-intent flow: no + /// subscription (or any Stripe object) exists until the user actually taps + /// Pay. Only then does the sheet invoke [onCreateSubscription]; the backend + /// creates the subscription and returns its intent client secret, which the + /// SDK confirms client-side. Dismissing the sheet without paying therefore + /// leaves nothing behind — no abandoned `incomplete` subscriptions on + /// Stripe or in our DB. + /// + /// [amount] is the plan's expected monthly price in USD cents. + /// + /// This is only used by android. Future startStripeSDK({ required BuildContext context, - required StripeOptions options, + required int amount, + required String email, + required Future Function() onCreateSubscription, required OnPressed onSuccess, required Function(dynamic error) onError, }) async { try { + appLogger.info( + 'Stripe: starting deferred-intent flow (amount: $amount cents)', + ); // Extract all context-dependent values before any async gap final brightness = Theme.of(context).brightness; final style = brightness == Brightness.dark @@ -52,37 +55,30 @@ class StripeService { error: AppColors.red4, placeholderText: context.textDisabled, ); - if (options.clientSecret.isEmpty && - options.setupIntentClientSecret.isEmpty) { - throw Exception( - 'Please try again after some time. If the issue persists, contact support.', - ); - } - if (options.publishableKey != null && - options.publishableKey!.isNotEmpty) { - Stripe.publishableKey = options.publishableKey!; - appLogger.info('Using provided publishable key for API calls'); - } - await Stripe.instance.applySettings(); - - /// Just a safety check to ensure the publishable key is set - /// before proceeding - if ((options.publishableKey != null && options.publishableKey!.isEmpty) || - Stripe.publishableKey.isEmpty) { - throw StateError('Missing STRIPE_PUBLISHABLE_KEY'); - } + // initPaymentSheet applies any pending settings (including the + // publishable key) to the native SDK itself. If plans never provided + // a key, this throws StripeConfigException into the catch below. await Stripe.instance.initPaymentSheet( paymentSheetParameters: SetupPaymentSheetParameters( - paymentIntentClientSecret: options.clientSecret.isEmpty - ? null - : options.clientSecret, - setupIntentClientSecret: options.setupIntentClientSecret.isEmpty - ? null - : options.setupIntentClientSecret, - customerId: options.customerId, + intentConfiguration: IntentConfiguration( + mode: IntentMode.paymentMode( + currencyCode: 'USD', + amount: amount, + // The subscription charges this payment method on renewal, so + // it must be saved for off-session reuse. + setupFutureUsage: IntentFutureUsage.OffSession, + ), + // The SDK confirms the intent itself with the payment method it + // collected, so neither callback argument is needed here. + confirmHandler: (_, _) => + _createSubscriptionAndConfirm(onCreateSubscription), + ), merchantDisplayName: 'Lantern Pro', allowsDelayedPaymentMethods: true, + // Prefill the checkout email so the user doesn't retype it; Stripe + // also uses it for receipts and Link lookup. + billingDetails: email.isEmpty ? null : BillingDetails(email: email), googlePay: PaymentSheetGooglePay( merchantCountryCode: 'US', currencyCode: 'USD', @@ -96,36 +92,86 @@ class StripeService { ), ); + appLogger.info('Stripe: payment sheet initialized, presenting'); await Stripe.instance.presentPaymentSheet(); + appLogger.info('Stripe: payment completed successfully'); onSuccess.call(); } catch (e) { - appLogger.error('Error presenting payment sheet: ${e.toString()}', e); + if (e is StripeException && e.error.code == FailureCode.Canceled) { + appLogger.info('Stripe: payment sheet dismissed by user'); + } else { + appLogger.error('Error presenting payment sheet: ${e.toString()}', e); + } onError.call(e); } } + + /// Runs inside the sheet's confirm step: creates the subscription on the + /// backend and hands its intent client secret back to the SDK via + /// intentCreationCallback (the reply channel matching confirmHandler). + /// Errors are reported the same way so the sheet surfaces them inline and + /// lets the user retry, instead of crashing the flow. + Future _createSubscriptionAndConfirm( + Future Function() onCreateSubscription, + ) async { + try { + appLogger.info('Stripe: user tapped Pay, creating subscription'); + final options = await onCreateSubscription(); + appLogger.info( + 'Stripe: subscription created ' + '(subscriptionId: ${options.subscriptionId}, ' + 'secret type: ${options.clientSecret.isNotEmpty ? 'payment' : 'setup'})', + ); + // Normal path returns a PaymentIntent secret; the trial path (user + // still has an unexpired one-time purchase) returns a SetupIntent + // secret instead. + final secret = options.clientSecret.isNotEmpty + ? options.clientSecret + : options.setupIntentClientSecret; + if (secret.isEmpty) { + throw Exception( + 'Please try again after some time. If the issue persists, contact support.', + ); + } + await Stripe.instance.intentCreationCallback( + IntentCreationCallbackParams(clientSecret: secret), + ); + appLogger.info('Stripe: client secret handed to SDK for confirmation'); + } catch (e) { + appLogger.error('Error creating subscription during confirm', e); + final message = e is StripeException + ? (e.error.localizedMessage ?? e.error.message) + : e.toString(); + await Stripe.instance.intentCreationCallback( + IntentCreationCallbackParams( + error: StripeException( + error: LocalizedErrorMessage( + code: FailureCode.Failed, + localizedMessage: message, + message: message, + ), + ), + ), + ); + } + } } class StripeOptions { - final String? publishableKey; final String clientSecret; final String setupIntentClientSecret; - final String customerId; final String subscriptionId; StripeOptions({ - this.publishableKey, required this.clientSecret, required this.setupIntentClientSecret, - required this.customerId, required this.subscriptionId, }); factory StripeOptions.fromJson(Map json) { return StripeOptions( - publishableKey: json['publishableKey'] ?? '', clientSecret: json['clientSecret'] ?? '', setupIntentClientSecret: json['pending_secret'] ?? '', - customerId: json['customerId'] ?? '', subscriptionId: json['subscriptionId'] ?? '', ); } diff --git a/lib/features/auth/choose_payment_method.dart b/lib/features/auth/choose_payment_method.dart index f5d988b6eb..7e5565af07 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -196,49 +196,52 @@ class ChoosePaymentMethod extends HookConsumerWidget { ) async { if (!beginPaymentRedirect(paymentRedirectInFlight)) return; final userPlan = ref.read(plansProvider.notifier).getSelectedPlan(); - final payments = ref.read(paymentProvider.notifier); - context.showLoadingDialog(); - - ///get stripe details - final result = await payments.stripeSubscription( - userPlan.id, - email, - couponCode: _affiliateCoupon(ref), + appLogger.info( + 'Stripe subscription flow started (plan: ${userPlan.id}, ' + 'amount: ${userPlan.monthlyUsdCents} cents/month)', ); - result.fold( - (error) { - context.showSnackBar(error.localizedErrorMessage); - appLogger.error('Error subscribing to plan: $error'); - context.hideLoadingDialog(); + + /// Deferred-intent flow: the sheet opens right away and the backend + /// subscription is only created once the user taps Pay (inside + /// onCreateSubscription), so dismissing the sheet leaves no abandoned + /// subscription behind. The flag is cleared inside the SDK callbacks + /// since startStripeSDK returns before the user finishes the flow. + sl().startStripeSDK( + context: context, + amount: userPlan.monthlyUsdCents, + email: email, + onCreateSubscription: () async { + // paymentProvider is autoDispose, so it must be read here at Pay-tap + // time — a notifier captured when the sheet opened is disposed by the + // time this callback runs, and using it throws. + appLogger.info('Stripe onCreateSubscription callback invoked'); + final payments = ref.read(paymentProvider.notifier); + final result = await payments.stripeSubscription( + userPlan.id, + email, + couponCode: _affiliateCoupon(ref), + ); + return result.fold( + (error) => throw Exception(error.localizedErrorMessage), + (stripeData) => StripeOptions.fromJson(stripeData), + ); + }, + onSuccess: () { finishPaymentRedirect(paymentRedirectInFlight); + onPurchaseResult(true, context, ref); }, - (stripeData) async { - // Handle success - context.hideLoadingDialog(); - - /// Start stripe SDK. The flag is cleared inside the SDK callbacks - /// since startStripeSDK returns before the user finishes the flow. - sl().startStripeSDK( - context: context, - options: StripeOptions.fromJson(stripeData), - onSuccess: () { - finishPaymentRedirect(paymentRedirectInFlight); - onPurchaseResult(true, context, ref); - }, - onError: (error) { - finishPaymentRedirect(paymentRedirectInFlight); + onError: (error) { + finishPaymentRedirect(paymentRedirectInFlight); - ///error while subscribing - appLogger.error('Error subscribing to plan: $error'); - if (error is StripeException) { - context.showSnackBar( - error.error.localizedMessage ?? error.localizedDescription, - ); - return; - } - context.showSnackBar(error.toString()); - }, - ); + ///error while subscribing + appLogger.error('Error subscribing to plan: $error'); + if (error is StripeException) { + context.showSnackBar( + error.error.localizedMessage ?? error.localizedDescription, + ); + return; + } + context.showSnackBar(error.toString()); }, ); } diff --git a/lib/features/plans/provider/plans_notifier.dart b/lib/features/plans/provider/plans_notifier.dart index 8c4bd11090..81002f78ea 100644 --- a/lib/features/plans/provider/plans_notifier.dart +++ b/lib/features/plans/provider/plans_notifier.dart @@ -4,6 +4,7 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/plan_data.dart'; import 'package:lantern/core/services/injection_container.dart' show sl; import 'package:lantern/core/services/local_storage_service.dart'; +import 'package:lantern/core/services/stripe_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -17,6 +18,18 @@ class PlansNotifier extends _$PlansNotifier { @override Future build() async { + // Every plans arrival (cache, fetch, referral update) funnels through + // state, so this one listener keeps the Stripe key in sync. StripeService + // is only registered on Android, hence the guard. + if (sl.isRegistered()) { + listenSelf( + (_, next) => next.whenData( + (plans) => + sl().updatePublishableKey(plans.stripePubKey), + ), + ); + } + state = const AsyncLoading(); final cached = _storage.getPlans(); if (cached != null) { From e07dea858baa7db4c221d5931ad20c3d1d6577c4 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Fri, 31 Jul 2026 15:58:24 +0530 Subject: [PATCH 2/3] hide user-sensitive errors --- lib/core/extensions/error.dart | 18 ++++++++++++++++++ lib/core/services/stripe_service.dart | 19 +++++++++++++++++-- lib/features/auth/choose_payment_method.dart | 15 ++++++++------- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/core/extensions/error.dart b/lib/core/extensions/error.dart index 6830e7309c..ea40872a2b 100644 --- a/lib/core/extensions/error.dart +++ b/lib/core/extensions/error.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:lantern/core/common/common.dart'; extension ErrorExetension on Object { @@ -155,6 +156,23 @@ String _stripIpcPrefix(String message) { return message; } +extension StripeErrorExtension on StripeException { + /// Stripe error types whose localizedMessage is written for the end user + /// (declined card, bad CVC, ...). Every other type — api_error, + /// authentication_error, invalid_request_error — carries developer text + /// (e.g. "Expired API Key provided: pk_live_…") that must never be shown. + static const _userFacingTypes = {'card_error', 'validation_error'}; + + String get localizedDescription { + if (_userFacingTypes.contains(error.type) || error.declineCode != null) { + return error.localizedMessage ?? + error.message ?? + 'an_error_occurred'.i18n; + } + return 'an_error_occurred'.i18n; + } +} + extension PurchaseErrorExtension on String { String get localizedDescription { if (this == 'BillingResponse.itemAlreadyOwned') { diff --git a/lib/core/services/stripe_service.dart b/lib/core/services/stripe_service.dart index 90b52e49b3..4ec41f1da1 100644 --- a/lib/core/services/stripe_service.dart +++ b/lib/core/services/stripe_service.dart @@ -139,9 +139,12 @@ class StripeService { appLogger.info('Stripe: client secret handed to SDK for confirmation'); } catch (e) { appLogger.error('Error creating subscription during confirm', e); + // Backend failures arrive as Exception(); + // strip the "Exception: " prefix. Stripe failures go through the same + // developer-text filter as the snackbar path. final message = e is StripeException - ? (e.error.localizedMessage ?? e.error.message) - : e.toString(); + ? e.userFacingMessage + : e.toString().replaceFirst('Exception: ', ''); await Stripe.instance.intentCreationCallback( IntentCreationCallbackParams( error: StripeException( @@ -157,6 +160,18 @@ class StripeService { } } +extension StripeErrorMessage on StripeException { + static const _hiddenErrorTypes = {'invalid_request_error'}; + + /// A message safe to show the user for this Stripe failure. + String get userFacingMessage { + if (_hiddenErrorTypes.contains(error.type)) { + return 'an_error_occurred'.i18n; + } + return error.localizedMessage ?? error.message ?? 'an_error_occurred'.i18n; + } +} + class StripeOptions { final String clientSecret; final String setupIntentClientSecret; diff --git a/lib/features/auth/choose_payment_method.dart b/lib/features/auth/choose_payment_method.dart index 7e5565af07..2ee7af0933 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -232,16 +232,17 @@ class ChoosePaymentMethod extends HookConsumerWidget { }, onError: (error) { finishPaymentRedirect(paymentRedirectInFlight); - - ///error while subscribing - appLogger.error('Error subscribing to plan: $error'); if (error is StripeException) { - context.showSnackBar( - error.error.localizedMessage ?? error.localizedDescription, - ); + // Dismissing the sheet is not an error — no snackbar. + if (error.error.code == FailureCode.Canceled) return; + appLogger.error('Error subscribing to plan: $error'); + // userFacingMessage filters out developer text (expired/invalid + // API key, bad request, ...) that Stripe puts in localizedMessage. + context.showSnackBar(error.userFacingMessage); return; } - context.showSnackBar(error.toString()); + appLogger.error('Error subscribing to plan: $error'); + context.showSnackBar((error as Object).localizedDescription); }, ); } From 4e9b4293da3eaf659653c7a22929fb67732a5c89 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Fri, 31 Jul 2026 18:34:11 +0530 Subject: [PATCH 3/3] code review updates --- lib/core/extensions/error.dart | 18 ------------------ lib/core/models/plan_data.dart | 6 ++++-- lib/core/services/injection_container.dart | 5 +++-- lib/core/services/stripe_service.dart | 10 +++++++++- lib/features/auth/choose_payment_method.dart | 4 ++++ 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/lib/core/extensions/error.dart b/lib/core/extensions/error.dart index ea40872a2b..6830e7309c 100644 --- a/lib/core/extensions/error.dart +++ b/lib/core/extensions/error.dart @@ -1,5 +1,4 @@ import 'package:flutter/services.dart'; -import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:lantern/core/common/common.dart'; extension ErrorExetension on Object { @@ -156,23 +155,6 @@ String _stripIpcPrefix(String message) { return message; } -extension StripeErrorExtension on StripeException { - /// Stripe error types whose localizedMessage is written for the end user - /// (declined card, bad CVC, ...). Every other type — api_error, - /// authentication_error, invalid_request_error — carries developer text - /// (e.g. "Expired API Key provided: pk_live_…") that must never be shown. - static const _userFacingTypes = {'card_error', 'validation_error'}; - - String get localizedDescription { - if (_userFacingTypes.contains(error.type) || error.declineCode != null) { - return error.localizedMessage ?? - error.message ?? - 'an_error_occurred'.i18n; - } - return 'an_error_occurred'.i18n; - } -} - extension PurchaseErrorExtension on String { String get localizedDescription { if (this == 'BillingResponse.itemAlreadyOwned') { diff --git a/lib/core/models/plan_data.dart b/lib/core/models/plan_data.dart index f4512b7123..acbd7637c2 100644 --- a/lib/core/models/plan_data.dart +++ b/lib/core/models/plan_data.dart @@ -6,9 +6,11 @@ class PlansData { PlansData({required this.providers, required this.plans}); - /// The payment methods offered on the current platform. + /// The payment methods offered on the current platform. Matches the + /// selection in ChoosePaymentMethod: only Android uses the android list; + /// iOS pays via IAP and falls back to the desktop list like everyone else. List get platformProviders => - PlatformUtils.isMobile ? providers.android : providers.desktop; + PlatformUtils.isAndroid ? providers.android : providers.desktop; /// Sorts plans (best-value first, then by descending price) and orders the /// platform's payment providers so subscription-capable ones come first. diff --git a/lib/core/services/injection_container.dart b/lib/core/services/injection_container.dart index 4c8631261d..589f0b49ff 100644 --- a/lib/core/services/injection_container.dart +++ b/lib/core/services/injection_container.dart @@ -76,8 +76,9 @@ Future injectServices() async { }); if (PlatformUtils.isAndroid) { - // The publishable key arrives with the plans response - // (PlansNotifier._syncStripeKey), so no upfront initialization. + // The publishable key arrives with the plans response (synced by the + // listenSelf listener in PlansNotifier.build), so no upfront + // initialization. sl.registerSingleton(StripeService()); appLogger.debug('StripeService registered'); } diff --git a/lib/core/services/stripe_service.dart b/lib/core/services/stripe_service.dart index 4ec41f1da1..ff76a9a353 100644 --- a/lib/core/services/stripe_service.dart +++ b/lib/core/services/stripe_service.dart @@ -161,7 +161,15 @@ class StripeService { } extension StripeErrorMessage on StripeException { - static const _hiddenErrorTypes = {'invalid_request_error'}; + /// API/integration error types hidden from the user: their messages carry + /// developer text (e.g. "Expired API Key provided: pk_live_…", which is + /// type api_error) rather than anything the user can act on. Everything + /// else (card declines, bad CVC, ...) shows Stripe's own localized message. + static const _hiddenErrorTypes = { + 'api_error', + 'authentication_error', + 'invalid_request_error', + }; /// A message safe to show the user for this Stripe failure. String get userFacingMessage { diff --git a/lib/features/auth/choose_payment_method.dart b/lib/features/auth/choose_payment_method.dart index 2ee7af0933..5ff5fc593e 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -227,10 +227,14 @@ class ChoosePaymentMethod extends HookConsumerWidget { ); }, onSuccess: () { + // These callbacks fire after async SDK work; the screen may have + // been disposed while the sheet was open. + if (!context.mounted) return; finishPaymentRedirect(paymentRedirectInFlight); onPurchaseResult(true, context, ref); }, onError: (error) { + if (!context.mounted) return; finishPaymentRedirect(paymentRedirectInFlight); if (error is StripeException) { // Dismissing the sheet is not an error — no snackbar.