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/models/app_setting.dart b/lib/core/models/app_setting.dart index 3f6eb7a6ee..dfd9f4db43 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -21,6 +21,10 @@ class AppSetting { this.onboardingCompleted = false, }); + /// Whether the app points at the staging backend. The notifier writes + /// 'stage'; 'staging' is accepted for older stored settings. + bool get isStaging => environment == 'stage' || environment == 'staging'; + AppSetting copyWith({ String? newLocale, String? themeMode, diff --git a/lib/core/models/plan_data.dart b/lib/core/models/plan_data.dart index 2ee56847e2..acbd7637c2 100644 --- a/lib/core/models/plan_data.dart +++ b/lib/core/models/plan_data.dart @@ -6,6 +6,12 @@ class PlansData { PlansData({required this.providers, required this.plans}); + /// 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.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. /// Applied after fetching/attaching plans (including referral V2). @@ -18,14 +24,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 +167,7 @@ class Android { class Provider { String name; - Map? data; + ProviderData data; List icons; bool supportSubscription; @@ -161,24 +175,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..589f0b49ff 100644 --- a/lib/core/services/injection_container.dart +++ b/lib/core/services/injection_container.dart @@ -75,22 +75,20 @@ Future injectServices() async { return service; }); - appLogger.debug('Initializing notification/Stripe services...'); + if (PlatformUtils.isAndroid) { + // 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'); + } + + 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..06fbb64abf 100644 --- a/lib/core/services/stripe_service.dart +++ b/lib/core/services/stripe_service.dart @@ -1,39 +1,51 @@ +import 'dart:async'; + 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; applySettings pushes a changed one to the + /// native SDK right away so it's already applied by the time the payment + /// sheet initializes. + void updatePublishableKey(String? pubKey) { + if (pubKey == null || pubKey.isEmpty) return; + Stripe.publishableKey = pubKey; + unawaited( + Stripe.instance.applySettings().catchError((Object e) { + // Non-fatal: initPaymentSheet re-applies pending settings itself. + appLogger.error('Stripe applySettings failed', e); + }), + ); } - // 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 price in USD cents (the sheet's display total). + /// + /// 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 +64,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 +101,110 @@ 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'})', + ); + // The sheet was initialized with IntentMode.paymentMode, so it can only + // confirm a PaymentIntent secret. The trial path (user still has an + // unexpired one-time purchase) returns a SetupIntent secret instead, + // which this sheet can't confirm — fail with a retryable message rather + // than hand the SDK a mismatched intent type. + final secret = options.clientSecret; + + 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); + // 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.userFacingMessage + : e.toString().replaceFirst('Exception: ', ''); + await Stripe.instance.intentCreationCallback( + IntentCreationCallbackParams( + error: StripeException( + error: LocalizedErrorMessage( + code: FailureCode.Failed, + localizedMessage: message, + message: message, + ), + ), + ), + ); + } + } +} + +extension StripeErrorMessage on StripeException { + /// 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 { + if (_hiddenErrorTypes.contains(error.type)) { + return 'an_error_occurred'.i18n; + } + return error.localizedMessage ?? error.message ?? 'an_error_occurred'.i18n; + } } 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..4fc7685b91 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -196,49 +196,61 @@ 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), + // usdPrice is the backend's plan price in USD cents — always USD, + // unlike the currency-keyed `price` map (local currency on CNY plans). + final amount = userPlan.usdPrice; + appLogger.info( + 'Stripe subscription flow started (plan: ${userPlan.id}, ' + 'amount: $amount cents)', ); - result.fold( - (error) { - context.showSnackBar(error.localizedErrorMessage); - appLogger.error('Error subscribing to plan: $error'); - context.hideLoadingDialog(); - finishPaymentRedirect(paymentRedirectInFlight); - }, - (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); - - ///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()); - }, + /// 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: amount, + 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: () { + // 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. + 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; + } + appLogger.error('Error subscribing to plan: $error'); + context.showSnackBar((error as Object).localizedDescription); }, ); } diff --git a/lib/features/developer/developer_mode.dart b/lib/features/developer/developer_mode.dart index a77cbdc96e..9da84879e8 100644 --- a/lib/features/developer/developer_mode.dart +++ b/lib/features/developer/developer_mode.dart @@ -110,10 +110,9 @@ class _DeveloperModeState extends ConsumerState { Widget _purchaseAndEnvironmentCard() { final developerMode = ref.watch(developerModeProvider); final devNotifier = ref.read(developerModeProvider.notifier); - final environment = ref.watch( - appSettingProvider.select((s) => s.environment), + final isStaging = ref.watch( + appSettingProvider.select((s) => s.isStaging), ); - final isStaging = environment == 'stage' || environment == 'staging'; return AppCard( padding: EdgeInsets.zero, child: Column( diff --git a/lib/features/plans/provider/plans_notifier.dart b/lib/features/plans/provider/plans_notifier.dart index 8c4bd11090..b3752ce8e2 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'; @@ -15,8 +16,26 @@ class PlansNotifier extends _$PlansNotifier { Plan? userSelectedPlan; + // build() reruns on the same notifier instance when the provider is + // invalidated, but listenSelf subscriptions survive rebuilds — without this + // guard every rebuild would stack another listener. + bool _stripeKeyListenerAttached = false; + @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 (!_stripeKeyListenerAttached && sl.isRegistered()) { + _stripeKeyListenerAttached = true; + listenSelf( + (_, next) => next.whenData( + (plans) => + sl().updatePublishableKey(plans.stripePubKey), + ), + ); + } + state = const AsyncLoading(); final cached = _storage.getPlans(); if (cached != null) { diff --git a/lib/lantern_app.dart b/lib/lantern_app.dart index e51555493e..a2d37de409 100644 --- a/lib/lantern_app.dart +++ b/lib/lantern_app.dart @@ -222,6 +222,7 @@ class _LanternAppState extends ConsumerState Widget build(BuildContext context) { final appSetting = ref.watch(appSettingProvider); final locale = appSetting.locale; + final isStaging = appSetting.isStaging; Localization.defaultLocale = locale; return GlobalLoaderOverlay( overlayColor: Theme.of(context).colorScheme.scrim.withValues(alpha: 0.5), @@ -242,6 +243,15 @@ class _LanternAppState extends ConsumerState child: MaterialApp.router( locale: locale.toLocale, debugShowCheckedModeBanner: false, + builder: (context, child) { + if (!isStaging) return child!; + return Banner( + message: 'STAGING', + location: BannerLocation.topEnd, + color: AppColors.red6, + child: child!, + ); + }, theme: AppTheme.appTheme(), darkTheme: AppTheme.darkTheme(), themeMode: resolveThemeMode(appSetting.themeMode),