flutter-stripe / flutter_stripe

Flutter SDK for Stripe.
https://pub.dev/packages/flutter_stripe
927 stars 511 forks source link

Android BLIK - The payment has been canceled #1497

Open wojciechzahradnikdeviniti opened 9 months ago

wojciechzahradnikdeviniti commented 9 months ago

Describe the bug I want to confirm a BLIK operation with passing a 6-digit code to BE during creating payment intent. Android returns error "The payment has been canceled" but I see that payment appears in Stripe dashboard as "Succeeded". iOS with the same implementation return "Payment successfully completed" and I also see that payment appears in Stripe dashboard as "Succeeded".

To Reproduce Steps to reproduce the behavior:

  1. You can use example from this repository and my code:

Server code:

app.post(
  '/create-payment-intent',
  async (
    req: express.Request,
    res: express.Response
  ): Promise<express.Response<any>> => {

    const payment_method_type: string = req.body['payment_method_type'];
    const blik_code: string | undefined = req.body['blik_code'];
    const bank: string | undefined = req.body['bank'];

    const { secret_key } = getKeys();

    const stripe = new Stripe(secret_key as string, {
      apiVersion: '2023-08-16',
      typescript: true,
    });

    if (payment_method_type == 'blik' && blik_code == undefined) {
      return res.send({
        error: 'blik_code is undefined',
      });
    } else if (payment_method_type == 'p24' && bank == undefined) {
      return res.send({
        error: 'bank is undefined',
      });
    }

    var payment_method_data_type: Stripe.PaymentIntentCreateParams.PaymentMethodData.Type;
    if (payment_method_type == 'blik') {
      payment_method_data_type = 'blik';
    } else if (payment_method_type == 'p24') {
      payment_method_data_type = 'p24';
    } else {
      return res.send({
        error: 'Pass payment_method_type blik or p24',
      });
    }

    var bank_type: Stripe.PaymentIntentCreateParams.PaymentMethodData.P24.Bank | undefined;
    if (bank == 'alior_bank') {
      bank_type = 'alior_bank';
    } else if (bank == 'bank_millennium') {
      bank_type = 'bank_millennium';
    } else if (bank == 'ing') {
      bank_type = 'ing';
    }

    // Create a PaymentIntent with the order amount and currency.
    const params: Stripe.PaymentIntentCreateParams = {
      confirm: payment_method_type == 'blik' ? true : undefined,
      amount: 1099,
      currency: "pln",
      payment_method_options: {
        p24: payment_method_type == 'p24' ? {} : undefined,
        blik: payment_method_type == 'blik' ? {
          code: blik_code,
        } : undefined,

      },
      payment_method_data: {
        type: payment_method_data_type,
        blik: payment_method_type == 'blik' ? {} : undefined,
        p24: payment_method_type == 'p24' ? {
          bank: bank_type,
        } : undefined,
        billing_details: payment_method_type == 'p24' ? {
          email: 'emailuzytkownika@gmail.com',
        } : undefined,
      },
      payment_method_types: [
        payment_method_type,
      ],
    };

    console.log(`create-payment-intent params ${params}`)

    try {
      const paymentIntent: Stripe.PaymentIntent =
        await stripe.paymentIntents.create(params);
      // Send publishable key and PaymentIntent client_secret to client.
      console.log(`create-payment-intent client_secret ${paymentIntent.client_secret}`)
      return res.send({
        clientSecret: paymentIntent.client_secret,
      });
    } catch (error: any) {
      console.log(`create-payment-intent error ${error}`)
      return res.send({
        error: error.raw.message,
      });
    }
  }
);

Mobile code:

Future<Map<String, dynamic>> _createPaymentIntent() async {
    final url = Uri.parse('$kApiUrl/create-payment-intent');
    final response = await http.post(
      url,
      headers: {
        'Content-Type': 'application/json',
      },
      body: json.encode({
        'payment_method_type': 'blik',
        'blik_code': '123456',
      }),
    );

    return json.decode(response.body);
  }
final result = await _createPaymentIntent();
    final clientSecret = await result['clientSecret'];

    // 2. use the client secret to confirm the payment and handle the result.
    try {
      await Stripe.instance.confirmPayment(
        paymentIntentClientSecret: clientSecret,
      );

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Payment succesfully completed'),
        ),
      );
    } on Exception catch (e) {
      if (e is StripeException) {
        print("error: ${e.error.message}");
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('Error from Stripe: ${e.error.localizedMessage}'),
          ),
        );
      } else {
        print("error: ${e}");
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('Unforeseen error: ${e}'),
          ),
        );
      }

Expected behavior Android returns no error and message "Payment successfully completed" as like on iOS.

Smartphone / tablet

Additional context I found that isNextActionSuccessState(result.nextActionType) in retrievePaymentIntent function in PaymentLauncherFragment.kt in reactnativestripesdk package returns false and thats why it returns error with the message "The payment has been canceled"

private fun retrievePaymentIntent(clientSecret: String, stripeAccountId: String?) {
    stripe.retrievePaymentIntent(clientSecret, stripeAccountId, expand = listOf("payment_method"), object : ApiResultCallback<PaymentIntent> {
      override fun onError(e: Exception) {
        promise.resolve(createError(ConfirmPaymentErrorType.Failed.toString(), e))
        removeFragment(context)
      }

      override fun onSuccess(result: PaymentIntent) {
        when (result.status) {
          StripeIntent.Status.Succeeded,
          StripeIntent.Status.Processing,
          StripeIntent.Status.RequiresConfirmation,
          StripeIntent.Status.RequiresCapture -> {
            promise.resolve(createResult("paymentIntent", mapFromPaymentIntentResult(result)))
          }
          StripeIntent.Status.RequiresAction -> {
            // nextActionType is BlikAuthorize and this method will return false
            if (isNextActionSuccessState(result.nextActionType)) {
              promise.resolve(createResult("paymentIntent", mapFromPaymentIntentResult(result)))
            } else {

              (result.lastPaymentError)?.let {
                promise.resolve(createError(ConfirmPaymentErrorType.Canceled.toString(), it))
              } ?: run {
                // Android will go here
                promise.resolve(createError(ConfirmPaymentErrorType.Canceled.toString(), "The payment has been canceled"))
              }
            }
          }
          StripeIntent.Status.RequiresPaymentMethod -> {
            promise.resolve(createError(ConfirmPaymentErrorType.Failed.toString(), result.lastPaymentError))
          }
          StripeIntent.Status.Canceled -> {
            promise.resolve(createError(ConfirmPaymentErrorType.Canceled.toString(), result.lastPaymentError))
          }
          else -> {
            promise.resolve(createError(ConfirmPaymentErrorType.Unknown.toString(), "unhandled error: ${result.status}"))
          }
        }
        removeFragment(context)
      }
    })
  }

On iOS StripeSdk.swift has different implementation for this confirmPayment

func onCompleteConfirmPayment(status: STPPaymentHandlerActionStatus, paymentIntent: STPPaymentIntent?, error: NSError?) {
        self.confirmPaymentClientSecret = nil
        switch (status) {
        case .failed:
            confirmPaymentResolver?(Errors.createError(ErrorType.Failed, error))
            break
        case .canceled:
            let statusCode: String
            if (paymentIntent?.status == STPPaymentIntentStatus.requiresPaymentMethod) {
                statusCode = ErrorType.Failed
            } else {
                statusCode = ErrorType.Canceled
            }
            if let lastPaymentError = paymentIntent?.lastPaymentError {
                confirmPaymentResolver?(Errors.createError(statusCode, lastPaymentError))
            } else {
                confirmPaymentResolver?(Errors.createError(statusCode, "The payment has been canceled"))
            }
            break
        case .succeeded:
            // iOS will go here
            if let paymentIntent = paymentIntent {
                let intent = Mappers.mapFromPaymentIntent(paymentIntent: paymentIntent)
                confirmPaymentResolver?(Mappers.createResult("paymentIntent", intent))
            }
            break
        @unknown default:
            confirmPaymentResolver?(Errors.createError(ErrorType.Unknown, "Cannot complete the payment"))
            break
        }
    }
remonh87 commented 9 months ago

Thanks for reporting we need to wait for Stripe to fix this behavior. I reported an issue on their sdk. https://github.com/stripe/stripe-react-native/issues/1571