Adyen / adyen-react-native

Adyen React Native
https://docs.adyen.com/checkout
MIT License
42 stars 32 forks source link

Returning URL for iOS issue #482

Open devic021 opened 6 days ago

devic021 commented 6 days ago

Describe the bug I guess that it not a bug, but i have issue with returnig URL on iOS, and returning to the app after finishing proccess of adding bank account as a payment method using session flow.

To Reproduce Steps to reproduce the behavior:

  1. Session is created with predefined returning URL for the app (that is already working fine with other web view component that i have)
  2. Press on "Change payment method" (i already have saved test credit card payment method)
  3. Press option "Pay by Bank"
  4. Complete all needed steps to add a test bank account as a payment method
  5. On the end of the proccess where i have option to select test bank account and i confirm it, it should close web view and return back to the app, but web view stay opened and only option i have is to Cancel, in that case web view is closed and i'm back on the drop-in component "Payment Methods" screen, but the bank account that i'm added is not there as a payment option.

Expected behavior On Android it is working fine, after compleating proccess of adding bank account, web view is closed and it return to the app with needed params.

Screenshots IMG_0013 IMG_0014

Smartphone (please complete the following information):

Additional context App is already have configured custom URL Scheme, and I added all needed settings for iOS in AppDelegate.mm as it is described in the documentation:

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
  return [ADYRedirectComponent applicationDidOpenURL:url] || [RCTLinkingManager application:application openURL:url options:options];
}

and

- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
  if ([[userActivity activityType] isEqualToString:NSUserActivityTypeBrowsingWeb]) {
   NSURL *url = [userActivity webpageURL];
    if (![url isEqual:[NSNull null]] && [ADYRedirectComponent applicationDidOpenURL:url]) {
      return YES;
    }
  }
  return [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
}
devic021 commented 5 days ago

And just to add, payment with 3D Secure cards is working as expected, it open up webview to autheticate purchase and on code submit web view is closed, back in the app, and the payment is successful.

descorp commented 1 day ago

Hey @devic021

Interesting case 🤔

On Android it is working fine

what url are you using for iOS and for Android?

payment with 3D Secure cards is working as expected

Is it Web-based 3DS (in Safari browser) or Native 3DS?

devic021 commented 1 day ago

Hello @descorp

In main Adyen's configuration returnUrl is defined as:

{ environment: 'test', clientKey: {MY_TEST_KEY}, countryCode: 'US', returnUrl: 'otaapp://', dropin: { title: 'Payment', }, };

I just need to return back to the app after compleating "pay by bank" web based flow.

This is setting in AndroidManifest.xml defined accodring to the Adyen's documentation:

<intent-filter>
 <action android:name="android.intent.action.VIEW" />
 <category android:name="android.intent.category.DEFAULT"/>
 <category android:name="android.intent.category.BROWSABLE" />
 <data android:scheme="otaapp" android:path="/payment" />
</intent-filter>

Also in the info.plist URL scheme is defined as:

<key>CFBundleURLSchemes</key>
 <array>
  <string>otaapp</string>
 </array>

I complete 3DS proccess by drop in, so it is native:

IMG_3195

So i guess that my initial question needs to be modified, 3DS has nothing to do with returnUrl on iOS...

descorp commented 1 day ago

Hey @devic021

Session is created with predefined returning URL

You are using /sessions, right? In this case returnUrl on AdyenCheckout have no effect (improvement of this API is in our backlog).

that is already working fine with other web view component that i have

Also on iOS?

devic021 commented 1 day ago

Hey @descorp

You are using /sessions, right? In this case returnUrl on AdyenCheckout have no effect (improvement of this API is in our backlog).

Yes, currently we are using /session flow, but the plan is to move on advanced flow ASAP, so the returnUrl will work in that case? Also on iOS?

Yes, we're using few components with web view (chat bot, vimeo video player, etc..), and it works on both platforms, iOS and android with deepLinking as expected.

descorp commented 1 day ago

we're using few components with web view

I thought you mean "Adyen Components" 😅

move on advanced flow ASAP, so the returnUrl will work in that case?

This problem is dictated by the fact that Android Drop-In only works with a dynamically calculated returnUrl that looks like adyencheckout://${DeviceInfo.getBundleId()} and can be calculated via AdyenDropIn.getReturnURL(). iOS DropIn and Components works with any URL, as long as it is propagated via [ADYRedirectComponent applicationDidOpenURL:url].

/sessions flow is only available for Drop-in on Android and also forces you to make your backend "platform-aware": you need actual dynamic value from adyencheckout://${DeviceInfo.getBundleId()} for Android and you can use any value for iOS.


BTW, We are planing to work on "embeded components" for React Native that will allow one to use any returnUrl on Android.

devic021 commented 23 hours ago

@descorp so basically best and most reliable solution is /advanced flow for both platforms?

Btw, we are using our backend to connect to the Adyen's API, as a response from request to our backend I got session object with all its data, this is the call to our backend:

try {
const authToken = (await Auth.currentSession()).getIdToken().getJwtToken();
const apiUrl = `${Config.API_BASE_URL}/payment/api/adyen/checkout`;
const headers: Headers = {
  Authorization: `Bearer ${authToken}`,
  'Content-Type': 'application/json',
  Accept: 'application/json',
};
const body = {
  host,
  channel,
  reference,
  countryCode,
  amount,
  code,
  merchantAccount,
  returnUrl,
};

const response = await postData(apiUrl, body, headers);

On the iOS, the body look like this:

{
  "host": {OUR_HOST_URL},
  "channel": "iOS",
  "reference": "exampleReference",
  "countryCode": "US",
  "amount": 1690,
  "code": "USD",
  "merchantAccount": {OUR_TEST_MERCH_ACCOUNT}
  "returnUrl": "otaapp://"
}

and the session look like this:

{
  "accountInfo": null,
  "additionalAmount": null,
  "additionalData": null,
  "allowedPaymentMethods": null,
  "amount": {
    "currency": "USD",
    "value": 169000
  },
  "applicationInfo": null,
  "authenticationData": null,
  "billingAddress": null,
  "blockedPaymentMethods": null,
  "captureDelayHours": null,
  "channel": "iOS",
  "company": null,
  "countryCode": "US",
  "dateOfBirth": null,
  "deliverAt": null,
  "deliveryAddress": null,
  "enableOneClick": null,
  "enablePayOut": null,
  "enableRecurring": null,
  "expiresAt": "2024-07-04T14:29:16+02:00",
  "fundOrigin": null,
  "fundRecipient": null,
  "id": "CSD94B3D14A86C909E",
  "installmentOptions": null,
  "lineItems": [
    {
      "amountExcludingTax": 0,
      "amountIncludingTax": 169000,
      "brand": null,
      "color": null,
      "description": "Online Purchase",
      "id": null,
      "imageUrl": null,
      "itemCategory": null,
      "manufacturer": null,
      "productUrl": null,
      "quantity": 1,
      "receiverEmail": null,
      "size": null,
      "sku": null,
      "taxAmount": 0,
      "taxPercentage": 0,
      "upc": null
    }
  ],
  "mandate": null,
  "mcc": null,
  "merchantAccount": {OUR_TEST_MERCH_ACCOUNT},
  "merchantOrderReference": null,
  "metadata": null,
  "mode": "embedded",
  "mpiData": null,
  "platformChargebackLogic": null,
  "recurringExpiry": null,
  "recurringFrequency": null,
  "recurringProcessingModel": "UnscheduledCardOnFile",
  "redirectFromIssuerMethod": null,
  "redirectToIssuerMethod": null,
  "reference": "exampleReference",
  "returnUrl": "otaapp://",
  "riskData": null,
  "sessionData": "Ab02b4c0!BQABAgBXUYABrt9xVW8cDbNjNCRz5h7T93yk/1XowA5ZEyFhoeXs5LvNddw49A95hO6Z0wIkGu1Z6cWkHFwVXQk3LazLDiCuDYxCJkHxMYlWbQ1JjMbHkpGck2dyh8TwDdYupn3AlxMdIe/WB77ym1jKLlq5CtYL+SinMK8TgC7svsNwqEeMAwleDwtmMOZikMIpNN4PDoxVWr8+QltltVP9355e3TcY/KAsw9Rc33ux8neqmqahLvm/k0fKbfLjH4xRRSsUuxC6c6FLIqJLYs1XDQE9IDY1exldhh7G8R+e0wRYAUbYQkwqTOWTcmzLif1WMdHcsECqRmPNGZoaBrWQT/algYguOuCqgr/fokqoaMsYCl8J7POuSWBcaL0a8b28Q+Ql+tBpMSFehp7KCZ35RaN3CFFBJfI3QICWL6LSHbwQltzWU2RqvoxXouM9VkEdqJrcH3mRFf6u7PlyBIQZgqfMHFbA08mj2vIR7t8bwWoo+z3oQdjz+weMWa1nZP2yWWKuZU/oLs1hyod+cQc3EDd6GYlgg6QAyNYV+rXeVF+zqWBGr4QWY+tEmfyz2Wk5IZ315t25lWPEJYeAKrc8WOI3aFLjCFHL4VJ1QUSzeMHZuLRaBoQg186DCgXvzSE0QUn/NnWoFmg7szfUjwIn6wHQ9ADuauNgNlgvmf0dCa0bNAwLZHyM6GCWFpFkqPIASnsia2V5IjoiQUYwQUFBMTAzQ0E1MzdFQUVEODdDMjRERDUzOTA5QjgwQTc4QTkyM0UzODIzRDY4REFDQzk0QjlGRjgzMDVEQyJ95qJwmZd5OyxXdwoiLMxsVplJsPclgKENuKPAK7I2FloO5nQHULNvZMIaYM+aKC4txv2E7YEg5vTR3vq+dxNfwGuHWioekyUWv8nPQw8RGp+Yg7G7IHU2AeoU21CDq+yYjFogJp6YJTucHIIjNVbccpCInWrmjj1kznDaDwhLWRoYYWzHXDv/mUDx9tb2tFQtXca2Z9e8uZaCTSOnwgM6XK3J2nrhgb7N1fc08J7th38mACkhsrC065EeEbE4yEx/9NbfTyVcagHwgnqWdd5OqiWCWy+6rfzlPELljurP10Z8+0Ldp8utoxFHsdEHIPhfCFPTEYAuZnv43/dsbEYDUcUnot/qk3PcT/bU1+Pk3OBZwhtt5Tup5d8Z/pM2rcyYC3cSuxSsYenUW14KjVCrIRb9KuTuQCXZM0zyqGxTRZuXv72+bQ/Ms70Jg+WBtqIawxnBs2NNq4S7F+z4FWEzgNMiZBqI637jI776t5k83PelKi1NTpZjUqoww0gXdmsGvs2nX5fpakfKX7C2d4Lp41qFG/22uBwtUBbXPEVxwRT0YGYzdqvkT3GsgLj4FIq+72r6uKkuhOk1zK0Ol2778MhImFNF2NaYmrsZH8EBs6VKYoRpg91Mr5vzBlE/fVcuXTx2cKsI2GGMIjA43fv6wzpQIQD8SLr+wYvy1DWAF+PmnwQm2IAtLhokga6KMmEaDSZBdfNvzwVQBreynjC0XBV8yZAzvM2CNfhvgfYRmaFdpE19WWggoDP3+UZO1R03WuAr4TC9EPXAFcoF6Jkh7LxtUuXOZtVRed2BBUQqsVL7iadnAemnPqw23KOxxJDzYCe/ZrbaER2GOwYefN4utJMkqo60Nwwd0G2iAPRcz1SAV1JwIvxyHqPz1xp+f0qqn+Vih6kjXxiHZ3ueykg322bi+tKstD3aZWzusWKysOse2U8c+ltiFpMFgWM1nB+L0gKXqZc9ZPJCxP+LZtJb4WW9cIErjZNc9vp8Fi8uSJW7m4ZXvjx87fJog+RJ8oqxQJOyJTVJt5y+HfDLvHfxQhiAUfsHGKV6kJAOHMR/WguDK4gJnbfO/PX+l7oplAyRgAQ=",
  "shopperEmail": null,
  "shopperIP": null,
  "shopperInteraction": "ContAuth",
  "shopperLocale": "en-US",
  "shopperName": null,
  "shopperReference": "323dd2df-8270-4fb4-bdb4-1ed5168f2f84",
  "shopperStatement": null,
  "showInstallmentAmount": null,
  "showRemovePaymentMethodButton": true,
  "socialSecurityNumber": null,
  "splitCardFundingSources": false,
  "splits": null,
  "store": null,
  "storePaymentMethod": null,
  "storePaymentMethodMode": "askForConsent",
  "telephoneNumber": null,
  "themeId": null,
  "threeDSAuthenticationOnly": false,
  "trustedShopper": null,
  "url": null
}

But if i try to Pay, and use "Pay By Bank Method" as i mentioned above already, here:

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
  return [ADYRedirectComponent applicationDidOpenURL:url] || [RCTLinkingManager application:application openURL:url options:options];
}

or here:

- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
  if ([[userActivity activityType] isEqualToString:NSUserActivityTypeBrowsingWeb]) {
   NSURL *url = [userActivity webpageURL];
    if (![url isEqual:[NSNull null]] && [ADYRedirectComponent applicationDidOpenURL:url]) {
      return YES;
    }
  }
  return [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
}

Returning URL is not passed and detected at all, but for other component it is and it is handled as it should be.

P.S. Honestly I'm getting more and more confused, where or am i doing something wrong, or am i just completally misunderstood implementation from the start?

descorp commented 23 hours ago

where or am i doing something wrong

You code looks fine to me. TBH I am more concerned how is your Android actually working 😅

we're using few components with web view

If otaapp:// is correctly configured "Custom URL" - you should be able to make a breakpoint on application :openURL:options: method on AppDelegate and hit it when redirecting to the app. If you hitting the breakpoint for your web-views but not for Adyen payment flow - could be that this particular payment method is malfunctioning. In this case - maybe you can verify it by trying other redirect PM (PayPal, Klarna etc)

devic021 commented 23 hours ago

@descorp is it possible that this payment method isn't configured as it should be in the Adyen web merchant admin interface?

Is this method even usable using /session flow at all? I'm bit confused...ok tbh not a bit, but very confused 🤣

P.S. when I enter otaapp:// in the Safari, app is started as it should be, landing on the Home Screen as there is no extra params for deepLinking added. I guess that the same principle should be with Adyen right? It just needs to return back to the app after add bank account web flow, in our case by Plaid.

P.S.S.I'm getting more and more desperate because the production is few month away, and we didn't manage this to work already... 🥶

descorp commented 22 hours ago

We will figure this one, no worries :)

when I enter otaapp:// in the Safari, app is started as it should be

This is good, we know that "Custom URL" works as expected.

just needs to return back to the app

If everything works well, you will hit [ADYRedirectComponent applicationDidOpenURL:url] and it will trigger onComplete callback. Then you need to call nativeComponent.hide(..); to dismiss SafariViewController

devic021 commented 22 hours ago

@descorp main problem is that I'm not so familiar with the native iOS code. So please have that on mind. I don't know what should I exactly need to change (if I need at all) in AppDelegate.m

Do you need some additional info about AppDelegate?

This is the complete AppDelegate:

#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
#import <UIKit/UIKit.h>
#import <adyen-react-native/ADYRedirectComponent.h>
#import <UserNotifications/UserNotifications.h>
#import <RNCPushNotificationIOS.h>
#import <Firebase.h>

@implementation AppDelegate

// Required for the register event.
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
 [RNCPushNotificationIOS didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Required for the notification event. You must call the completion handler after handling the remote notification.
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
  [RNCPushNotificationIOS didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
// Required for the registrationError event.
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
 [RNCPushNotificationIOS didFailToRegisterForRemoteNotificationsWithError:error];
}
// Required for localNotification event
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
         withCompletionHandler:(void (^)(void))completionHandler
{
  [RNCPushNotificationIOS didReceiveNotificationResponse:response];
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  if ([FIRApp defaultApp] == nil) {
    [FIRApp configure];
  }

  self.moduleName = @"otaApp";
  // You can add your custom initial props in the dictionary below.
  // They will be passed down to the ViewController used by React Native.
  self.initialProps = @{};

  // Define UNUserNotificationCenter
  UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
  center.delegate = self;

  return [super application:application didFinishLaunchingWithOptions:launchOptions];

}

//Called when a notification is delivered to a foreground app.
-(void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler
{
  if (@available(iOS 14.0, *)) {
    completionHandler(UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList | UNNotificationPresentationOptionBadge);
  } else {
    completionHandler(UNNotificationPresentationOptionSound | UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge);
  }
}

// Proccess custom URL schema OLD WAY
//- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options
//{
//  return [RCTLinkingManager application:application openURL:url options:options];
//  return [ADYRedirectComponent applicationDidOpenURL:url];
//}

// Process custom URL schema NEW WAY
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options
{
  return [ADYRedirectComponent applicationDidOpenURL:url] || [RCTLinkingManager application:application openURL:url options:options];
}

// Process Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler
{
  if ([[userActivity activityType] isEqualToString:NSUserActivityTypeBrowsingWeb]) {
    NSURL *url = [userActivity webpageURL];
    if (![url isEqual:[NSNull null]]) {
      return [ADYRedirectComponent applicationDidOpenURL:url];
    }
  }
  return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [self bundleURL];
}

- (NSURL *)bundleURL
{
#if DEBUG
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
  return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}

@end

P.S. I know that i'm sound so dumb with this...But i really need to resolve this issue... :)

devic021 commented 22 hours ago

I even added logging in AppDelegate, but when the Adyen web view needs to be closed, I'm not getting any url at all. Logs are empty for both universal linking and with custom URL scheme.

descorp commented 22 hours ago

Your code seems fine to me.

but when the Adyen web view needs to be closed,

I'll run local tests with this "Pay by Bank" flow to verify.


If things goes really dim - please contact our Support Team via Customer Area or via email: support@adyen.com

They can help arranging a call with development team.

devic021 commented 22 hours ago

I will, if you don't mind, I'll wait a bit until you test it. I'll contact them after.

Just please keep me posted so do I know what to do next, and thank you very much for the effort man!

descorp commented 2 hours ago

Hey @devic021

I have a good and a bad news: Good news - your setup is most likely working perfectly fine. Bad news - It seems like the "Plaid" is not supported for iOS Custom URL scheme.

Locally on iOS, I have the same behavior as you do in case my returnUrl is a Custom URL scheme. However, it works perfectly fine for Android Drop-In or if "returnUrl" is an https://.

This could be the Sandbox issue (I am testing on First Platypus Bank and haven't tried app-2-app flow) and may not occur for a real bank's app/website. Unfortunately, there is no guaranty that all 12000 issuers will work as expected. To know more - I would suggest doing a "penny test".

As a solution - you can try using Universal link for iOS.


As alternative: Adyen has a tool named "Gluepage" that provides extra step for issuer-to-app redirects - it routs redirects through a secure Adyen web page before reaching returnUrl. This usually creates extra friction for mobile redirects, but maybe in this case it can serve as a temporary solution. You can try reaching out Support Team via Customer Area or email: support@adyen.com and ask them to turn this setting for you (if applicable).


PS. On iOS redirect from Plaid happens in case I am canceling the payment (cross on top-right corner). It causes an error on Adyen API that I am about to address, but at least it leads back to the app. Does this work for you the same way?


I will reach out internal LPM team, but since this behavior is on the Plaid side, I doubt Adyen can help with this.

devic021 commented 1 hour ago

Hey @descorp

First of all thank you so much for all of this. I'm forward your message to the rest of the backend team that was working on the Adyen and Plaid integration. I'll probably have some informations on the Monday about that, I'm also asked them about that "gluepage" feature, so I expect answers about that too.

Can you provide me with some shot guidance about Universal link, if I'm not asking too much?

P.S. About Plaid payment cancellation on X, I'm getting back to the app also with this error:

image

image

Thank you very much in advance.

descorp commented 1 hour ago

P.S. About Plaid payment cancellation on X, I'm getting back to the app also with this error:

Good to know we are on a same page :)

guidance about Universal link

All I have is official Apple documentation, sorry.

One needs to Supporting associated domains on the backend. AppDelegate part you have already done.

devic021 commented 14 minutes ago

@descorp Thank you so much, now I have better and bigger picture of all. Currently we're in the dev stage still but as I said already production is getting close so we need to harden this part of the app and it is crucial part. As soon as I get response from my BE team, I'll probably arrange a meeting with our support agent from Adyen. I just wanted to be clear that my part of the integration I correct. From all of this I have a feeling that from my side everything is done as it should be, right?