From 7a5bb7e3e7834abdf29a2ad8523f10f78da4d90a Mon Sep 17 00:00:00 2001 From: cedvdb Date: Sun, 17 Nov 2024 08:51:54 +0100 Subject: [PATCH] Setup confirm stripe web (#1989) * wip * add confirm setup * confirm setup * add confirm to api * wip * exports * wip * add_confirm_setup * changelog & versioning" * changelog and versioning * add example * fix return body of create setup * fix web example * fix example * fix comments * version --------- Co-authored-by: cedvandenbosch --- .../others/setup_future_payment_screen.dart | 239 ----------------- example/lib/screens/screens.dart | 15 +- .../_create_setup_intent.dart | 30 +++ .../add_payment_method_button.dart | 42 +++ .../add_payment_method_screen_loader.dart | 11 + ...payment_method_screen_loader_abstract.dart | 8 + ...d_payment_method_screen_loader_mobile.dart | 128 +++++++++ .../add_payment_method_screen_loader_web.dart | 152 +++++++++++ .../setup_future_payments_screen.dart | 17 ++ example/server/src/index.ts | 3 +- packages/stripe_web/CHANGELOG.md | 4 + .../stripe_web/lib/flutter_stripe_web.dart | 3 +- .../confirm_payment_options.freezed.dart | 32 ++- .../lib/src/models/confirm_setup_options.dart | 25 ++ .../models/confirm_setup_options.freezed.dart | 251 ++++++++++++++++++ .../src/models/confirm_setup_options.g.dart | 38 +++ .../stripe_web/lib/src/models/models.dart | 1 + packages/stripe_web/lib/src/web_stripe.dart | 17 ++ packages/stripe_web/pubspec.yaml | 4 +- 19 files changed, 763 insertions(+), 257 deletions(-) delete mode 100644 example/lib/screens/others/setup_future_payment_screen.dart create mode 100644 example/lib/screens/setup_future_payments/_create_setup_intent.dart create mode 100644 example/lib/screens/setup_future_payments/add_payment_method_button.dart create mode 100644 example/lib/screens/setup_future_payments/add_payment_method_screen_loader.dart create mode 100644 example/lib/screens/setup_future_payments/add_payment_method_screen_loader_abstract.dart create mode 100644 example/lib/screens/setup_future_payments/add_payment_method_screen_loader_mobile.dart create mode 100644 example/lib/screens/setup_future_payments/add_payment_method_screen_loader_web.dart create mode 100644 example/lib/screens/setup_future_payments/setup_future_payments_screen.dart create mode 100644 packages/stripe_web/lib/src/models/confirm_setup_options.dart create mode 100644 packages/stripe_web/lib/src/models/confirm_setup_options.freezed.dart create mode 100644 packages/stripe_web/lib/src/models/confirm_setup_options.g.dart diff --git a/example/lib/screens/others/setup_future_payment_screen.dart b/example/lib/screens/others/setup_future_payment_screen.dart deleted file mode 100644 index 45be5d61a..000000000 --- a/example/lib/screens/others/setup_future_payment_screen.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:flutter/material.dart' hide Card; -import 'package:flutter_stripe/flutter_stripe.dart'; -import 'package:http/http.dart' as http; -import 'package:stripe_example/config.dart'; -import 'package:stripe_example/screens/payment_sheet/payment_sheet_screen_custom_flow.dart'; -import 'package:stripe_example/utils.dart'; -import 'package:stripe_example/widgets/example_scaffold.dart'; -import 'package:stripe_example/widgets/loading_button.dart'; -import 'package:stripe_example/widgets/response_card.dart'; - -class SetupFuturePaymentScreen extends StatefulWidget { - @override - _SetupFuturePaymentScreenState createState() => - _SetupFuturePaymentScreenState(); -} - -class _SetupFuturePaymentScreenState extends State { - PaymentIntent? _retrievedPaymentIntent; - CardFieldInputDetails? _card; - SetupIntent? _setupIntentResult; - String _email = 'email@stripe.com'; - - int step = 0; - - @override - Widget build(BuildContext context) { - return ExampleScaffold( - title: 'Setup Future Payment', - children: [ - Padding( - padding: EdgeInsets.all(16), - child: TextFormField( - initialValue: _email, - decoration: InputDecoration(hintText: 'Email', labelText: 'Email'), - onChanged: (value) { - setState(() { - _email = value; - }); - }, - ), - ), - Padding( - padding: EdgeInsets.all(16), - child: CardField( - onCardChanged: (card) { - setState(() { - _card = card; - }); - }, - ), - ), - Stepper( - controlsBuilder: emptyControlBuilder, - currentStep: step, - steps: [ - Step( - title: Text('Save card'), - content: LoadingButton( - onPressed: _card?.complete == true ? _handleSavePress : null, - text: 'Save', - ), - ), - Step( - title: Text('Pay with saved card'), - content: LoadingButton( - onPressed: _setupIntentResult != null - ? _handleOffSessionPayment - : null, - text: 'Pay with saved card off-session', - ), - ), - Step( - title: Text('[Extra] Recovery Flow - Authenticate payment'), - content: Column( - children: [ - Text( - 'If the payment did not succeed. Notify your customer to return to your application to complete the payment. We recommend creating a recovery flow in your app that shows why the payment failed initially and lets your customer retry.'), - SizedBox(height: 8), - LoadingButton( - onPressed: _retrievedPaymentIntent != null - ? _handleRecoveryFlow - : null, - text: 'Authenticate payment (recovery flow)', - ), - ], - ), - ), - ], - ), - if (_setupIntentResult != null) - Padding( - padding: EdgeInsets.all(16), - child: ResponseCard( - response: _setupIntentResult!.toJson().toPrettyString(), - ), - ), - ], - ); - } - - Future _handleSavePress() async { - if (_card == null) { - return; - } - try { - // 1. Create setup intent on backend - final clientSecret = await _createSetupIntentOnBackend(_email); - - // 2. Gather customer billing information (ex. email) - final billingDetails = BillingDetails( - name: "Test User", - email: 'email@stripe.com', - phone: '+48888000888', - address: Address( - city: 'Houston', - country: 'US', - line1: '1459 Circle Drive', - line2: '', - state: 'Texas', - postalCode: '77063', - ), - ); // mo/ mocked data for tests - - // 3. Confirm setup intent - - final setupIntentResult = await Stripe.instance.confirmSetupIntent( - paymentIntentClientSecret: clientSecret, - params: PaymentMethodParams.card( - paymentMethodData: PaymentMethodData( - billingDetails: billingDetails, - ), - ), - ); - log('Setup Intent created $setupIntentResult'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Success: Setup intent created.', - ), - ), - ); - setState(() { - step = 1; - _setupIntentResult = setupIntentResult; - }); - } catch (error) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Error code: $error'))); - rethrow; - } - } - - Future _handleOffSessionPayment() async { - final res = await _chargeCardOffSession(); - if (res['error'] != null) { - // If the PaymentIntent has any other status, the payment did not succeed and the request fails. - // Notify your customer e.g., by email, text, push notification) to complete the payment. - // We recommend creating a recovery flow in your app that shows why the payment failed initially and lets your customer retry. - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - 'Error!: The payment could not be completed! ${res['error']}'))); - await _handleRetrievePaymentIntent(res['clientSecret']); - } else { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Success!: The payment was confirmed successfully!'))); - setState(() { - step = 2; - }); - } - - log('charge off session result: $res'); - } - - // When customer back to the App to complete the payment, retrieve the PaymentIntent via clientSecret. - // Check the PaymentIntent’s lastPaymentError to inspect why the payment attempt failed. - // For card errors, you can show the user the last payment error’s message. Otherwise, you can show a generic failure message. - Future _handleRetrievePaymentIntent(String clientSecret) async { - final paymentIntent = - await Stripe.instance.retrievePaymentIntent(clientSecret); - - final paymentMethodId = paymentIntent.paymentMethodId == null - ? _setupIntentResult?.paymentMethodId - : paymentIntent.paymentMethodId; - - setState(() { - _retrievedPaymentIntent = - paymentIntent.copyWith(paymentMethodId: paymentMethodId); - }); - } - - // https://stripe.com/docs/payments/save-and-reuse?platform=ios#start-a-recovery-flow - Future _handleRecoveryFlow() async { - // TODO lastPaymentError - if (_retrievedPaymentIntent?.paymentMethodId != null && _card != null) { - await Stripe.instance.confirmPayment( - paymentIntentClientSecret: _retrievedPaymentIntent!.clientSecret, - data: PaymentMethodParams.cardFromMethodId( - paymentMethodData: PaymentMethodDataCardFromMethod( - paymentMethodId: _retrievedPaymentIntent!.paymentMethodId!), - ), - ); - } - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Success!: The payment was confirmed successfully!'))); - } - - Future _createSetupIntentOnBackend(String email) async { - final url = Uri.parse('$kApiUrl/create-setup-intent'); - final response = await http.post( - url, - headers: { - 'Content-Type': 'application/json', - }, - body: json.encode({ - 'email': email, - }), - ); - final Map bodyResponse = json.decode(response.body); - final clientSecret = bodyResponse['clientSecret'] as String; - log('Client token $clientSecret'); - - return clientSecret; - } - - Future> _chargeCardOffSession() async { - final url = Uri.parse('$kApiUrl/charge-card-off-session'); - final response = await http.post( - url, - headers: { - 'Content-Type': 'application/json', - }, - body: json.encode({'email': _email}), - ); - return json.decode(response.body); - } -} diff --git a/example/lib/screens/screens.dart b/example/lib/screens/screens.dart index d71b1a9d0..6de0dc1b3 100644 --- a/example/lib/screens/screens.dart +++ b/example/lib/screens/screens.dart @@ -17,6 +17,7 @@ import 'package:stripe_example/screens/regional_payment_methods/paypal_screen.da import 'package:stripe_example/screens/regional_payment_methods/revolutpay_screen.dart'; import 'package:stripe_example/screens/regional_payment_methods/sofort_screen.dart'; import 'package:stripe_example/screens/regional_payment_methods/us_bank_account.dart'; +import 'package:stripe_example/screens/setup_future_payments/setup_future_payments_screen.dart'; import 'package:stripe_example/screens/wallets/apple_pay_screen.dart'; import 'package:stripe_example/screens/wallets/apple_pay_screen_plugin.dart'; import 'package:stripe_example/screens/wallets/google_pay_screen.dart'; @@ -33,7 +34,6 @@ import 'financial_connections.dart/financial_connections_session_screen.dart'; import 'others/cvc_re_collection_screen.dart'; import 'others/legacy_token_bank_screen.dart'; import 'others/legacy_token_card_screen.dart'; -import 'others/setup_future_payment_screen.dart'; import 'regional_payment_methods/grab_pay_screen.dart'; import 'themes.dart'; import 'wallets/apple_pay_create_payment_method.dart'; @@ -131,6 +131,15 @@ class Example extends StatelessWidget { DevicePlatform.web, ], ), + Example( + title: 'Setup future payments', + builder: (_) => SetupFuturePaymentsScreen(), + platformsSupported: [ + DevicePlatform.android, + DevicePlatform.ios, + DevicePlatform.web, + ], + ) ], expanded: true, ), @@ -356,10 +365,6 @@ class Example extends StatelessWidget { ], ), ExampleSection(title: 'Others', children: [ - Example( - title: 'Setup Future Payment', - builder: (c) => SetupFuturePaymentScreen(), - ), Example( title: 'Re-collect CVC', builder: (c) => CVCReCollectionScreen(), diff --git a/example/lib/screens/setup_future_payments/_create_setup_intent.dart b/example/lib/screens/setup_future_payments/_create_setup_intent.dart new file mode 100644 index 000000000..5f005eb03 --- /dev/null +++ b/example/lib/screens/setup_future_payments/_create_setup_intent.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:stripe_example/config.dart'; + +typedef SetupKeys = ({ + String clientSecret, + String customerId, +}); + +Future createSetupIntent() async { + final url = Uri.parse('$kApiUrl/create-setup-intent'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + }, + body: json.encode({ + 'payment_method_types': ['card', 'sepa_debit'], + }), + ); + final body = json.decode(response.body); + if (body['error'] != null) { + throw Exception(body['error']); + } + return ( + clientSecret: body['clientSecret'] as String, + customerId: body['customerId'] as String + ); +} diff --git a/example/lib/screens/setup_future_payments/add_payment_method_button.dart b/example/lib/screens/setup_future_payments/add_payment_method_button.dart new file mode 100644 index 000000000..f17976946 --- /dev/null +++ b/example/lib/screens/setup_future_payments/add_payment_method_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'add_payment_method_screen_loader.dart' + if (dart.library.js) 'add_payment_method_screen_loader_web.dart' + if (dart.library.io) 'add_payment_method_screen_loader_mobile.dart'; + +class AddPaymentMethodButton extends StatefulWidget { + const AddPaymentMethodButton({ + super.key, + }); + + @override + State createState() => _AddPaymentMethodButtonState(); +} + +class _AddPaymentMethodButtonState extends State { + bool isLoading = false; + + void _onAddPaymentMethodPressed() async { + setState(() => isLoading = true); + try { + AddPaymentMethodScreenLoader().display( + context: context, + ); + } finally { + setState(() => isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: isLoading + ? SizedBox( + height: 16, width: 16, child: const CircularProgressIndicator()) + : const Icon(Icons.credit_card), + title: Text('Add payment method'), + trailing: const Icon(Icons.chevron_right), + onTap: isLoading ? null : _onAddPaymentMethodPressed, + ); + } +} diff --git a/example/lib/screens/setup_future_payments/add_payment_method_screen_loader.dart b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader.dart new file mode 100644 index 000000000..83a03b34a --- /dev/null +++ b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class AddPaymentMethodScreenLoader { + const AddPaymentMethodScreenLoader(); + + Future display({ + required BuildContext context, + }) { + throw UnimplementedError(); + } +} diff --git a/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_abstract.dart b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_abstract.dart new file mode 100644 index 000000000..33e377e92 --- /dev/null +++ b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_abstract.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +/// abstract class so both mobile and web use the same constructor +abstract class AddPaymentMethodScreenLoaderAbstract { + const AddPaymentMethodScreenLoaderAbstract(); + + Future display({required BuildContext context}); +} diff --git a/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_mobile.dart b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_mobile.dart new file mode 100644 index 000000000..e77f90741 --- /dev/null +++ b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_mobile.dart @@ -0,0 +1,128 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_stripe/flutter_stripe.dart' as stripe; +import 'package:flutter_stripe/flutter_stripe.dart'; + +import '_create_setup_intent.dart'; + +class AddPaymentMethodScreenLoader { + const AddPaymentMethodScreenLoader(); + + Future display({ + required BuildContext context, + }) async { + final setupKeys = await createSetupIntent(); + + await Stripe.instance.initPaymentSheet( + paymentSheetParameters: SetupPaymentSheetParameters( + // Main params + setupIntentClientSecret: setupKeys.clientSecret, + customerId: setupKeys.customerId, + merchantDisplayName: 'Comover', + returnURL: 'flutterstripe://redirect', + allowsDelayedPaymentMethods: true, + allowsRemovalOfLastSavedPaymentMethod: false, + intentConfiguration: IntentConfiguration( + mode: IntentMode.setupMode( + currencyCode: 'EUR', + setupFutureUsage: IntentFutureUsage.OffSession, + ), + paymentMethodTypes: ['card', 'bancontact'], + ), + billingDetails: BillingDetails( + name: 'Flutter Stripe', + email: 'email@stripe.com', + phone: '+48888000888', + address: Address( + city: 'Houston', + country: 'US', + line1: '1459 Circle Drive', + line2: '', + state: 'Texas', + postalCode: '77063', + ), + ), + billingDetailsCollectionConfiguration: + BillingDetailsCollectionConfiguration( + address: AddressCollectionMode.full, + ), + // Customer params + // Extra params + // applePay: const PaymentSheetApplePay( + // merchantCountryCode: 'BE', + // ), + // googlePay: PaymentSheetGooglePay( + // merchantCountryCode: 'BE', + // label: 'ADD', + // testEnv: kDebugMode, + // ), + primaryButtonLabel: 'confirm', + style: Theme.of(context).brightness == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light, + appearance: _buildSheetAppearance(context), + ), + ); + try { + await Stripe.instance.presentPaymentSheet(); + } catch (e) { + if (e is StripeException) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error from Stripe: ${e.error.localizedMessage}'), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Unforeseen error: ${e}'), + ), + ); + } + } + } + + PaymentSheetAppearance _buildSheetAppearance(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return PaymentSheetAppearance( + colors: PaymentSheetAppearanceColors( + background: ElevationOverlay.applySurfaceTint( + colors.surface, + colors.surfaceTint, + 3, + ), + primary: colors.primary, + primaryText: colors.onSurface, + secondaryText: colors.onSurfaceVariant, + error: colors.error, + placeholderText: colors.onSurfaceVariant, + componentBackground: colors.secondaryContainer, + componentText: colors.onSecondaryContainer, + componentDivider: colors.outline, + componentBorder: colors.outline, + icon: colors.onSurface, + ), + shapes: PaymentSheetShape( + borderWidth: 0, + shadow: stripe.PaymentSheetShadowParams( + color: Colors.transparent, + opacity: 0, + ), + ), + primaryButton: PaymentSheetPrimaryButtonAppearance( + colors: PaymentSheetPrimaryButtonTheme( + light: PaymentSheetPrimaryButtonThemeColors( + background: colors.primary, + text: colors.onPrimary, + border: null, + ), + dark: PaymentSheetPrimaryButtonThemeColors( + background: colors.primary, + text: colors.onPrimary, + border: null, + ), + ), + ), + ); + } +} diff --git a/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_web.dart b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_web.dart new file mode 100644 index 000000000..8497d1994 --- /dev/null +++ b/example/lib/screens/setup_future_payments/add_payment_method_screen_loader_web.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_stripe_web/flutter_stripe_web.dart'; + +import '_create_setup_intent.dart'; + +extension ToHex on Color { + String toRgb() => 'rgb($red, $green, $blue)'; +} + +class AddPaymentMethodScreenLoader { + const AddPaymentMethodScreenLoader(); + + Future display({ + required BuildContext context, + }) async { + final setupKeys = await createSetupIntent(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _AddPaymentMethodScreenWeb( + setupKeys: setupKeys, + ), + ), + ); + } +} + +class _AddPaymentMethodScreenWeb extends StatefulWidget { + final SetupKeys setupKeys; + + const _AddPaymentMethodScreenWeb({ + required this.setupKeys, + }); + + @override + State<_AddPaymentMethodScreenWeb> createState() => + _AddPaymentMethodScreenPlatformState(); +} + +class _AddPaymentMethodScreenPlatformState + extends State<_AddPaymentMethodScreenWeb> { + bool isSubmitting = false; + bool isComplete = false; + + void _addCard() async { + setState(() => isSubmitting = true); + // will redirect so the next setState should not happen + // unless there is an error + try { + await WebStripe.instance.confirmSetupElement( + ConfirmSetupElementOptions( + confirmParams: ConfirmSetupParams(return_url: Uri.base.toString()), + ), + ); + } finally { + setState(() => isSubmitting = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Add payment method'), + ), + body: Column( + children: [ + SizedBox(height: 12), + Text( + 'This will redirect to the bank in production. In the example it\'s going to redirect to home with the result in the url'), + SizedBox(height: 12), + Padding( + padding: const EdgeInsets.all(24), + child: PaymentElement( + clientSecret: widget.setupKeys.clientSecret, + onCardChanged: (c) { + setState(() => isComplete = c?.complete ?? false); + }, + layout: PaymentElementLayout.tabs, + appearance: buildAppearance(context), + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: isComplete ? _addCard : null, + child: Text('Add'), + ), + ], + ), + ); + } + + ElementAppearance buildAppearance(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + print(theme.colorScheme.surface.toRgb()); + + return ElementAppearance( + theme: isDark ? ElementTheme.night : ElementTheme.stripe, + variables: { + 'fontFamily': 'roboto, system-ui, sans-serif', + 'colorBackground': theme.colorScheme.surface.toRgb(), + 'colorPrimary': theme.colorScheme.onSurface.toRgb(), + 'colorText': theme.colorScheme.onSurface.toRgb(), + 'colorTextSecondary': theme.colorScheme.onSurfaceVariant.toRgb(), + 'colorTextPlaceholder': theme.colorScheme.onSurfaceVariant.toRgb(), + 'colorSuccess': theme.colorScheme.tertiary.toRgb(), + 'colorDanger': theme.colorScheme.error.toRgb(), + }, + rules: { + '.AccordionItem': { + 'backgroundColor': theme.colorScheme.surface.toRgb(), + 'border': '0', + 'boxShadow': 'none', + }, + '.Input': { + 'backgroundColor': theme.colorScheme.surface.toRgb(), + 'color': theme.colorScheme.onSurface.toRgb(), + 'borderColor': '#a08d85', + 'borderRadius': '0', + 'borderTop': '0', + 'borderLeft': '0', + 'borderRight': '0', + 'boxShadow': '0', + }, + '.Input:focus': { + 'backgroundColor': theme.colorScheme.surface.toRgb(), + 'borderColor': theme.colorScheme.primary.toRgb(), + 'borderRadius': '0', + 'borderTop': '0', + 'borderLeft': '0', + 'borderRight': '0', + 'boxShadow': '0', + }, + '.Input--invalid': { + 'backgroundColor': theme.colorScheme.surface.toRgb(), + 'borderColor': theme.colorScheme.error.toRgb(), + 'borderRadius': '0', + 'borderTop': '0', + 'borderLeft': '0', + 'borderRight': '0', + 'boxShadow': '0', + }, + '.Block': { + 'backgroundColor': theme.colorScheme.surface.toRgb(), + 'boxShadow': 'none', + 'border': '1px solid ${theme.colorScheme.outline.toRgb()}', + }, + }, + ); + } +} diff --git a/example/lib/screens/setup_future_payments/setup_future_payments_screen.dart b/example/lib/screens/setup_future_payments/setup_future_payments_screen.dart new file mode 100644 index 000000000..f037218ef --- /dev/null +++ b/example/lib/screens/setup_future_payments/setup_future_payments_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:stripe_example/screens/setup_future_payments/add_payment_method_button.dart'; +import 'package:stripe_example/widgets/example_scaffold.dart'; + +class SetupFuturePaymentsScreen extends StatelessWidget { + const SetupFuturePaymentsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + title: 'Setup future payments', + children: [ + AddPaymentMethodButton(), + ], + ); + } +} diff --git a/example/server/src/index.ts b/example/server/src/index.ts index 2828d8c00..c6c01059d 100644 --- a/example/server/src/index.ts +++ b/example/server/src/index.ts @@ -377,6 +377,7 @@ app.post('/create-setup-intent', async (req, res) => { return res.send({ publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, clientSecret: setupIntent.client_secret, + customerId: customer.id, }); }); @@ -619,7 +620,7 @@ app.post('/payment-sheet-subscription', async (_, res) => { } else { throw new Error( 'Expected response type string, but received: ' + - typeof subscription.pending_setup_intent + typeof subscription.pending_setup_intent ); } }); diff --git a/packages/stripe_web/CHANGELOG.md b/packages/stripe_web/CHANGELOG.md index 5804518e8..e41aa4b5c 100644 --- a/packages/stripe_web/CHANGELOG.md +++ b/packages/stripe_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.3.0 +**Features** +- Add support for any kind of payment method in setup intent with [confirmSetup] + ## 6.2.0 **Features** - Add basic support for Expresscheckout on the web diff --git a/packages/stripe_web/lib/flutter_stripe_web.dart b/packages/stripe_web/lib/flutter_stripe_web.dart index 7a85ebd8f..6758f1f21 100644 --- a/packages/stripe_web/lib/flutter_stripe_web.dart +++ b/packages/stripe_web/lib/flutter_stripe_web.dart @@ -1,6 +1,7 @@ library stripe_web; -export 'package:stripe_js/stripe_api.dart' show ConfirmPaymentParams; +export 'package:stripe_js/stripe_api.dart' + show ConfirmPaymentParams, ConfirmSetupParams; export 'package:stripe_platform_interface/stripe_platform_interface.dart'; export 'src/models/models.dart'; diff --git a/packages/stripe_web/lib/src/models/confirm_payment_options.freezed.dart b/packages/stripe_web/lib/src/models/confirm_payment_options.freezed.dart index 8f3cca8be..fcbf0a786 100644 --- a/packages/stripe_web/lib/src/models/confirm_payment_options.freezed.dart +++ b/packages/stripe_web/lib/src/models/confirm_payment_options.freezed.dart @@ -12,7 +12,7 @@ part of 'confirm_payment_options.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); ConfirmPaymentElementOptions _$ConfirmPaymentElementOptionsFromJson( Map json) { @@ -38,8 +38,12 @@ mixin _$ConfirmPaymentElementOptions { PaymentConfirmationRedirect? get redirect => throw _privateConstructorUsedError; + /// Serializes this ConfirmPaymentElementOptions to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of ConfirmPaymentElementOptions + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $ConfirmPaymentElementOptionsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -70,6 +74,8 @@ class _$ConfirmPaymentElementOptionsCopyWithImpl<$Res, // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of ConfirmPaymentElementOptions + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -88,6 +94,8 @@ class _$ConfirmPaymentElementOptionsCopyWithImpl<$Res, ) as $Val); } + /// Create a copy of ConfirmPaymentElementOptions + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $ConfirmPaymentParamsCopyWith<$Res> get confirmParams { @@ -124,6 +132,8 @@ class __$$ConfirmPaymentElementOptionsImplCopyWithImpl<$Res> $Res Function(_$ConfirmPaymentElementOptionsImpl) _then) : super(_value, _then); + /// Create a copy of ConfirmPaymentElementOptions + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -178,7 +188,7 @@ class _$ConfirmPaymentElementOptionsImpl } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$ConfirmPaymentElementOptionsImpl && @@ -188,11 +198,13 @@ class _$ConfirmPaymentElementOptionsImpl other.redirect == redirect)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, confirmParams, redirect); - @JsonKey(ignore: true) + /// Create a copy of ConfirmPaymentElementOptions + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$ConfirmPaymentElementOptionsImplCopyWith< @@ -218,12 +230,10 @@ abstract class _ConfirmPaymentElementOptions factory _ConfirmPaymentElementOptions.fromJson(Map json) = _$ConfirmPaymentElementOptionsImpl.fromJson; - @override - /// Parameters that will be passed on to the Stripe API. /// Refer to the Payment Intents API for a full list of parameters. - ConfirmPaymentParams get confirmParams; @override + ConfirmPaymentParams get confirmParams; /// By default, stripe.confirmPayment will always redirect to your /// return_url after a successful confirmation. @@ -235,9 +245,13 @@ abstract class _ConfirmPaymentElementOptions /// methods separately. When a non-redirect based payment method is /// successfully confirmed, stripe.confirmPayment will resolve with a /// {paymentIntent} object. + @override PaymentConfirmationRedirect? get redirect; + + /// Create a copy of ConfirmPaymentElementOptions + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$ConfirmPaymentElementOptionsImplCopyWith< _$ConfirmPaymentElementOptionsImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/packages/stripe_web/lib/src/models/confirm_setup_options.dart b/packages/stripe_web/lib/src/models/confirm_setup_options.dart new file mode 100644 index 000000000..21ef60c9d --- /dev/null +++ b/packages/stripe_web/lib/src/models/confirm_setup_options.dart @@ -0,0 +1,25 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stripe_js/stripe_api.dart'; +export 'package:stripe_js/stripe_api.dart' + show SetupConfirmationRedirect, ConfirmSetupParams; + +part 'confirm_setup_options.freezed.dart'; +part 'confirm_setup_options.g.dart'; + +@freezed +class ConfirmSetupElementOptions with _$ConfirmSetupElementOptions { + const factory ConfirmSetupElementOptions({ + /// Parameters that will be passed on to the Stripe API. + /// Refer to the Payment Intents API for a full list of parameters. + required ConfirmSetupParams confirmParams, + + /// By default, stripe.confirmPayment will always redirect to + /// your return_url after a successful confirmation. + /// If you set redirect: "if_required", then stripe.confirmPayment + /// will only redirect if your user chooses a redirect-based payment method. + SetupConfirmationRedirect? redirect, + }) = _SetupPaymentElementOptions; + + factory ConfirmSetupElementOptions.fromJson(Map json) => + _$ConfirmSetupElementOptionsFromJson(json); +} diff --git a/packages/stripe_web/lib/src/models/confirm_setup_options.freezed.dart b/packages/stripe_web/lib/src/models/confirm_setup_options.freezed.dart new file mode 100644 index 000000000..a5fb605bd --- /dev/null +++ b/packages/stripe_web/lib/src/models/confirm_setup_options.freezed.dart @@ -0,0 +1,251 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'confirm_setup_options.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +ConfirmSetupElementOptions _$ConfirmSetupElementOptionsFromJson( + Map json) { + return _SetupPaymentElementOptions.fromJson(json); +} + +/// @nodoc +mixin _$ConfirmSetupElementOptions { + /// Parameters that will be passed on to the Stripe API. + /// Refer to the Setup Intents API for a full list of parameters. + ConfirmSetupParams get confirmParams => throw _privateConstructorUsedError; + + /// By default, stripe.confirmPayment will always redirect to your + /// return_url after a successful confirmation. + /// If you set redirect: "if_required", then stripe.confirmPayment will + /// only redirect if your user chooses a redirect-based payment method. + /// + /// Note: Setting if_required requires that you handle successful + /// confirmations for redirect-based and non-redirect based payment + /// methods separately. When a non-redirect based payment method is + /// successfully confirmed, stripe.confirmPayment will resolve with a + /// {paymentIntent} object. + SetupConfirmationRedirect? get redirect => throw _privateConstructorUsedError; + + /// Serializes this ConfirmSetupElementOptions to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ConfirmSetupElementOptions + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ConfirmSetupElementOptionsCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ConfirmSetupElementOptionsCopyWith<$Res> { + factory $ConfirmSetupElementOptionsCopyWith(ConfirmSetupElementOptions value, + $Res Function(ConfirmSetupElementOptions) then) = + _$ConfirmSetupElementOptionsCopyWithImpl<$Res, + ConfirmSetupElementOptions>; + @useResult + $Res call( + {ConfirmSetupParams confirmParams, SetupConfirmationRedirect? redirect}); + + $ConfirmSetupParamsCopyWith<$Res> get confirmParams; +} + +/// @nodoc +class _$ConfirmSetupElementOptionsCopyWithImpl<$Res, + $Val extends ConfirmSetupElementOptions> + implements $ConfirmSetupElementOptionsCopyWith<$Res> { + _$ConfirmSetupElementOptionsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ConfirmSetupElementOptions + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? confirmParams = null, + Object? redirect = freezed, + }) { + return _then(_value.copyWith( + confirmParams: null == confirmParams + ? _value.confirmParams + : confirmParams // ignore: cast_nullable_to_non_nullable + as ConfirmSetupParams, + redirect: freezed == redirect + ? _value.redirect + : redirect // ignore: cast_nullable_to_non_nullable + as SetupConfirmationRedirect?, + ) as $Val); + } + + /// Create a copy of ConfirmSetupElementOptions + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ConfirmSetupParamsCopyWith<$Res> get confirmParams { + return $ConfirmSetupParamsCopyWith<$Res>(_value.confirmParams, (value) { + return _then(_value.copyWith(confirmParams: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$SetupPaymentElementOptionsImplCopyWith<$Res> + implements $ConfirmSetupElementOptionsCopyWith<$Res> { + factory _$$SetupPaymentElementOptionsImplCopyWith( + _$SetupPaymentElementOptionsImpl value, + $Res Function(_$SetupPaymentElementOptionsImpl) then) = + __$$SetupPaymentElementOptionsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {ConfirmSetupParams confirmParams, SetupConfirmationRedirect? redirect}); + + @override + $ConfirmSetupParamsCopyWith<$Res> get confirmParams; +} + +/// @nodoc +class __$$SetupPaymentElementOptionsImplCopyWithImpl<$Res> + extends _$ConfirmSetupElementOptionsCopyWithImpl<$Res, + _$SetupPaymentElementOptionsImpl> + implements _$$SetupPaymentElementOptionsImplCopyWith<$Res> { + __$$SetupPaymentElementOptionsImplCopyWithImpl( + _$SetupPaymentElementOptionsImpl _value, + $Res Function(_$SetupPaymentElementOptionsImpl) _then) + : super(_value, _then); + + /// Create a copy of ConfirmSetupElementOptions + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? confirmParams = null, + Object? redirect = freezed, + }) { + return _then(_$SetupPaymentElementOptionsImpl( + confirmParams: null == confirmParams + ? _value.confirmParams + : confirmParams // ignore: cast_nullable_to_non_nullable + as ConfirmSetupParams, + redirect: freezed == redirect + ? _value.redirect + : redirect // ignore: cast_nullable_to_non_nullable + as SetupConfirmationRedirect?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SetupPaymentElementOptionsImpl implements _SetupPaymentElementOptions { + const _$SetupPaymentElementOptionsImpl( + {required this.confirmParams, this.redirect}); + + factory _$SetupPaymentElementOptionsImpl.fromJson( + Map json) => + _$$SetupPaymentElementOptionsImplFromJson(json); + + /// Parameters that will be passed on to the Stripe API. + /// Refer to the Setup Intents API for a full list of parameters. + @override + final ConfirmSetupParams confirmParams; + + /// By default, stripe.confirmPayment will always redirect to your + /// return_url after a successful confirmation. + /// If you set redirect: "if_required", then stripe.confirmPayment will + /// only redirect if your user chooses a redirect-based payment method. + /// + /// Note: Setting if_required requires that you handle successful + /// confirmations for redirect-based and non-redirect based payment + /// methods separately. When a non-redirect based payment method is + /// successfully confirmed, stripe.confirmPayment will resolve with a + /// {paymentIntent} object. + @override + final SetupConfirmationRedirect? redirect; + + @override + String toString() { + return 'ConfirmSetupElementOptions(confirmParams: $confirmParams, redirect: $redirect)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SetupPaymentElementOptionsImpl && + (identical(other.confirmParams, confirmParams) || + other.confirmParams == confirmParams) && + (identical(other.redirect, redirect) || + other.redirect == redirect)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, confirmParams, redirect); + + /// Create a copy of ConfirmSetupElementOptions + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SetupPaymentElementOptionsImplCopyWith<_$SetupPaymentElementOptionsImpl> + get copyWith => __$$SetupPaymentElementOptionsImplCopyWithImpl< + _$SetupPaymentElementOptionsImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SetupPaymentElementOptionsImplToJson( + this, + ); + } +} + +abstract class _SetupPaymentElementOptions + implements ConfirmSetupElementOptions { + const factory _SetupPaymentElementOptions( + {required final ConfirmSetupParams confirmParams, + final SetupConfirmationRedirect? redirect}) = + _$SetupPaymentElementOptionsImpl; + + factory _SetupPaymentElementOptions.fromJson(Map json) = + _$SetupPaymentElementOptionsImpl.fromJson; + + /// Parameters that will be passed on to the Stripe API. + /// Refer to the Setup Intents API for a full list of parameters. + @override + ConfirmSetupParams get confirmParams; + + /// By default, stripe.confirmPayment will always redirect to your + /// return_url after a successful confirmation. + /// If you set redirect: "if_required", then stripe.confirmPayment will + /// only redirect if your user chooses a redirect-based payment method. + /// + /// Note: Setting if_required requires that you handle successful + /// confirmations for redirect-based and non-redirect based payment + /// methods separately. When a non-redirect based payment method is + /// successfully confirmed, stripe.confirmPayment will resolve with a + /// {paymentIntent} object. + @override + SetupConfirmationRedirect? get redirect; + + /// Create a copy of ConfirmSetupElementOptions + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SetupPaymentElementOptionsImplCopyWith<_$SetupPaymentElementOptionsImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/packages/stripe_web/lib/src/models/confirm_setup_options.g.dart b/packages/stripe_web/lib/src/models/confirm_setup_options.g.dart new file mode 100644 index 000000000..39911f9d0 --- /dev/null +++ b/packages/stripe_web/lib/src/models/confirm_setup_options.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'confirm_setup_options.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SetupPaymentElementOptionsImpl _$$SetupPaymentElementOptionsImplFromJson( + Map json) => + _$SetupPaymentElementOptionsImpl( + confirmParams: ConfirmSetupParams.fromJson( + Map.from(json['confirmParams'] as Map)), + redirect: $enumDecodeNullable( + _$SetupConfirmationRedirectEnumMap, json['redirect']), + ); + +Map _$$SetupPaymentElementOptionsImplToJson( + _$SetupPaymentElementOptionsImpl instance) { + final val = { + 'confirmParams': instance.confirmParams.toJson(), + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull( + 'redirect', _$SetupConfirmationRedirectEnumMap[instance.redirect]); + return val; +} + +const _$SetupConfirmationRedirectEnumMap = { + SetupConfirmationRedirect.always: 'always', + SetupConfirmationRedirect.ifRequired: 'if_required', +}; diff --git a/packages/stripe_web/lib/src/models/models.dart b/packages/stripe_web/lib/src/models/models.dart index 7b2dbb387..9c28e7d19 100644 --- a/packages/stripe_web/lib/src/models/models.dart +++ b/packages/stripe_web/lib/src/models/models.dart @@ -1 +1,2 @@ export 'confirm_payment_options.dart'; +export 'confirm_setup_options.dart'; diff --git a/packages/stripe_web/lib/src/web_stripe.dart b/packages/stripe_web/lib/src/web_stripe.dart index 2679d7dd9..51f0dfee7 100644 --- a/packages/stripe_web/lib/src/web_stripe.dart +++ b/packages/stripe_web/lib/src/web_stripe.dart @@ -409,6 +409,23 @@ class WebStripe extends StripePlatform { } } + Future confirmSetupElement( + ConfirmSetupElementOptions options, + ) async { + final response = await js.confirmSetup( + stripe_js.ConfirmSetupOptions( + elements: elements!, + confirmParams: options.confirmParams, + redirect: options.redirect, + ), + ); + if (response.error != null) { + throw response.error!; + } else { + return; + } + } + @override Widget buildCard({ Key? key, diff --git a/packages/stripe_web/pubspec.yaml b/packages/stripe_web/pubspec.yaml index e0b7c2a86..5f29bcd8f 100644 --- a/packages/stripe_web/pubspec.yaml +++ b/packages/stripe_web/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_stripe_web description: Stripe sdk bindings for the Flutter web platform. This package contains the implementation of the platform interface for web. -version: 6.2.0 +version: 6.3.0 homepage: https://github.com/flutter-stripe/flutter_stripe environment: @@ -14,7 +14,7 @@ dependencies: sdk: flutter freezed_annotation: ^2.0.3 stripe_platform_interface: ^11.2.0 - stripe_js: ^6.2.0 + stripe_js: ^6.2.1 web: ^1.0.0 dev_dependencies: