Shopify / unity-buy-sdk

The Unity Buy SDK allows Unity developers to query and sell products from Shopify directly in Unity.
https://help.shopify.com/api/sdks/custom-storefront/unity-buy-sdk
MIT License
67 stars 23 forks source link

Add Native Payments #65

Closed dengjeffrey closed 7 years ago

dengjeffrey commented 7 years ago

What

Allow the SDK to support Apple Pay and Android Pay as part of the checkout flow.

How

Integrating native in app payments require the use of Native Plugins

Checklist

dengjeffrey commented 7 years ago

Below is my thought process and possible solution to implementing this. I have not experimented on Android thoroughly so any input will be helpful

@mikkoh

Edited May 10, 2017 3:51PM:

How

iOS

Android

Proposed Native Payment Flow

  1. On a checkout screen we will make a call Cart.CanCheckoutWithNativePay() -> Bool which determines if Apple Pay or Android Pay is available on the device. Based on the result the we will show a Native Pay button.

    iOS
    • If false is returned on an iOS device, we can decide if we would like to support an integrated Apple Pay Setup using Cart.CanShowApplePaySetup() -> Bool to determine if the device supports setting up a new card. If so we can display a setup Apple Pay Button and notify the Swift plugin when it is pressed to display a native setup.
    Android
    • This function is omitted on Android because Android does not supply a function that displays a native setup. The Android Pay Tutorial suggests that we show an alert to setup Android Pay, but by providing webcheckout as an alternative payment method it would not make sense to display this alert.
  2. Once a Native Pay button is pressed, we will call Cart.PrepareNativeCheckout(string key, Action<WalletInfo> success, Action<CheckoutFailureType ,string> failure) to continue the checkout process.

    • Action<WalletInfo> completion is the callback for a success
    • Action<CheckoutFailureType , string> failure) is a callback for failure, the string contains the error message, and CheckoutFailureType is a enum.
    • Since the results of the calls made in PrepareNativeCheckout will be returned in a different method, the Actions are stored in an instance variable to be invoked in a different function.
      • As brought up by @dbart01, we could be using opaque pointers to pass references between the plugins to allow for multiple sessions. I chose to use an instance variable to hold Action because we will not encounter the case where their are multiple concurrent payment sessions, and this simplifies the implementation.
    Android
    • key is the Private Key for encrypting and decrypting Wallet, supplied by the implementor
    • Action<WalletInfo> success, WalletInfo will be set to the info provided in a MaskedWallet
    1. PrepareNativeCheckout(...) tells the Android Plugin to create a MaskedWalletRequest and fetch a MaskedWallet.

    2. A native UI sheet appears on the screen after requesting to fetch a MaskedWallet. This is where the user chooses a payment option and fills out their shipping address.

    3. When the user dismisses the sheet, a MaskedWallet is returned to an Android delegate method and the Android Plugin will pass a serialized MaskedWallet to Unity via DidFetchMaskedWallet(string walletData) or DidCancelCheckout()

    4. We will display the MaskedWallet info to the user so that they can confirm that the shipping address and payment card is correct.

      • We do not use a WalletFragment to facilitate modifications to the MaskedWallet because we can not specify where to place a WalletFragment without having to subclass UnityPlayerActivity which is adding more restrictions for the implementor. Instead we will provide a hook in the Android Plugin to access changeMaskedWallet()
      • The MaskedWallet is stored in the Android Plugin for future requests when we create a FullWallet
      // Unity
      
      enum CheckoutFailureType {Cancelled, Error}
      
      void PrepareNativeCheckout(string key, Action<WalletInfo> success, Action<CheckoutFailureType, string> failure) {
      
          // Instance variables   
          onPrepareCheckoutSuccess = success;
          onCheckoutFailure = failure;
      
          #if UNITY_ANDROID
          // _Function denotes a function exposed from our Android Plugin
          // Android Plugin will create a MaskedWalletRequest and fetch a MaskedWallet
          _FetchMaskedWallet(publicKey, Store.name, Store.currencyCode);
      
          // When the Android Plugin recieves the MaskedWallet
          // it will call DidFetchMaskedWallet
          #endif
      } 
      
      void DidFetchMaskedWallet(string walletData) {
          // Parse walletData
          WalletInfo info = WalletInfo(walletData);
          onPrepareCheckoutSuccess(info);
      }
      
      void DidCancelCheckout() {
          onCheckoutFailure(CheckoutFailureType.Cancelled, null);
      }
      
      void DidFailToFetchMaskedWallet(string error) {
          onCheckoutFailure(CheckoutFailureType.Error, error);
      }
      
    5. Once the user has pressed the confirm button we will call CompleteNativeCheckout() which tells the Android Plugin to create a FullWalletRequest and fetch a FullWallet

      • The process for this is similar to fetching a MaskedWallet
    6. The payment token from FullWallet is passed to Unity where we will complete the token checkout

      // Unity
      void CompleteNativeCheckout(Action success, Action<CheckoutFailureType, string> failure) {
      
          // Instance variables   
          onCompleteCheckoutSuccess = success;
          onCheckoutFailure = failure;
      
          #if UNITY_ANDROID
          // _Function denotes a function exposed from our Android Plugin
          // Android Plugin will create a MaskedWalletRequest and fetch a MaskedWallet
          _FetchFullWallet();
      
          // When the Android Plugin receives the FullWallet
          // it will call DidFetchPaymentToken
          #endif
      } 
      
      void DidFetchFullWallet(string token) {
          // Send token to server to be processed
          CheckoutWithToken(token, (success, errorMessage) => {
      
              if (success) {
                  onCompleteCheckoutSuccess();
              }
              else {
                  onCheckoutFailure(CheckoutFailureType.Error, errorMessage);
              }
          })
      }
      
      void DidCancelCheckout() {
          onCheckoutFailure(CheckoutFailureType.Cancelled, null);
      }
      
      void DidFailToFetchMaskedWallet(string error) {
          onCheckoutFailure(CheckoutFailureType.Error, error);
      }
      
    iOS
    • key is the Merchant ID of the Apps, supplied by the implementor
    • Action<WalletInfo> success, WalletInfo will be set to null since there is no info received by iOS during this step
    1. PrepareNativeCheckout (string merchantID) consists of 3 sub steps handled by the Swift Plugin.

      1. Create a PKPaymentRequest
      2. Create PKSummaryItem from CartLineItem and other Cart details
      3. Present PKPaymentAuthorizationViewController on top of the current ViewController
      // Unity
      void PrepareNativeCheckout(string key, Action<WalletInfo> success, Action<CheckoutFailureType, string> failure) {
      
          // Instance variables   
          onPrepareCheckoutSuccess = success;
          onCheckoutFailure = failure;
      
          #if UNITY_IOS
          // _Function denotes a function exposed from a C# wrapper of ObjC/Swift methods
      
          // Create a PKPaymentRequest
          _CreateApplePaySession(key, Store.countryCode, Store.currencyCode, requiresBilling))
      
          // Directly call completion, there is no point in catching failure.
          // _CreateApplePaySession will crash on a failure.
          // If it does crash then Store.countryCode and currency code are not ISO codes
          // Which means there is an issue with the values returned by the server or 
          // our SDK parses the data incorrectly which is a fatal error
          onPrepareCheckoutSuccess(null)
      
          #endif
      } 
    2. The implementor then calls CompleteNativeCheckout

      // Unity
      void CompleteNativeCheckout(Action success, Action<CheckoutFailureType, string> failure) {
         // Instance variables    
         onCompleteCheckoutSuccess = success;
         onCheckoutFailure = failure;
      
         #if UNITY_IOS
         // Create PKSummaryItems
         _AddApplePaySummaryItem("Cart Total", Cart.total());
         _AddApplePaySummaryItem("Shipping Rate", Cart.shippingRate());
         _AddApplePaySummaryItem("Taxes", Cart.taxes());
         ...
         // Create PKShippingItems
         _AddApplePayShippingMethod("Free", "Takes 5-10 business days", Cart.ShippingRates[0].cost)     
         ...
      
         // Present PKPaymentAuthorizationViewController
         _PresentApplePayAuthorization();
         #endif
       }
      • Changes to the shipping address and payment will be handled by thePKPaymentAuthorizationViewControllerDelegate in the Swift Plugin.
        • When the delegate is called to update the summary items. We will send the updated information to Unity and block till summary items are updated. We need to block because calls to Unity are asynchronous, so we must wait till the we have recalculated the cart.
      // Swift Plugin
      func paymentAuthorizationViewController(PKPaymentAuthorizationViewController, 
          didSelect shippingMethod: PKShippingMethod,
          completion: (PKPaymentAuthorizationStatus, [PKPaymentSummaryItem]) -> Void) {
          // Notify Unity of a change, this call is asynchronous
          UnityWrapper.sendMessage(shippingMethod.identifier, toObject:"CartObject", withMethodName:"UpdateShippingForIdentifier")
      
          DispatchQueue.global(qos: .userInitiated).async {
              self.updateSemaphore.wait()
              completion(self.summaryItems)
          }
      }
      
      func didUpdateSummaryItems() {
          updateSemaphore.signal()
      }       
      // Unity
      void UpdateSummaryItemsForShippingIdentifier(string identifier) {
          // Update the summary items and notify Swift Plugin
          _RemoveAllApplePaySummaryItems();
      
          ...
      
          _AddApplePaySummaryItem("Total", 4.0f);         
      
          _DidUpdateApplePaySummaryItems(); 
      }
      • When a token is returned by the Swift Plugin, we send that to the server and recieve a status. Before calling the completion block or the failure, we must dismiss the Authorization View Controller by sending the status to the plugin.

Issues Encountered

Proposed solution

  1. Subclass UnityAppController, set _didResignActive = false when the app notifies Unity that the App is resigning active due to displaying the PKAuthorizationController

    • I discussed this solution with @dbart01, it was the best solution compared to the other alternatives.
    • By subclassing the App Controller, it will not require the implementor to change any of the generated Unity classes.
    • The subclass is simple enough, that any implementor can subclass it knowing exactly what they are doing, or move the functionality into their existing code
    
    extern bool _didResignActive;
    
    IMPL_APP_CONTROLLER_SUBCLASS(BuyUnityAppController)
    
    @interface BuyUnityAppController()
    
    @end
    
    @implementation BuyUnityAppController
    
    - (id)init {
        if (self = [super init]) {
            _shouldResignActive = true;
        }
        return self;
    }
    
    - (void)applicationWillResignActive:(UIApplication*)application
    {
        [super applicationWillResignActive:application];
        if (!_shouldResignActive) {
            _didResignActive = false;
        }
    }
    
  2. Provide GraphQL hooks in the iOS plugin to update checkout.
    • This is not ideal, because we would be repeating alot of functionality.
    • We would also need to maintain it in the future
mikkoh commented 7 years ago

@davidmuzi @dbart01 do you guys mind checking this issue out? It's a proposal on how to implement Apple Pay and Android Pay into the Unity SDK.

dbart01 commented 7 years ago

I'm not sure there's any benefit (aside from syntax) in using Swift for Apple Pay implementation. Since all classes defined in Swift have to stay compatible with ObjC (can't use rawValue backed enum, can't use generics, etc), there's really not much benefit in using Swift compared to just Objective-C. What you do gain with ObjC though is direct compatibility with Objective-C++, just rename to .mm and you're set. Whether the nice Swift syntax is worth the trade-off with C++ compatibility if your call but just something to keep in mind. It might eliminate some boiler-plate wrappers.

mikkoh commented 7 years ago

This looks pretty good! I think we still need to think about this. After reading this it’s still unclear to me how this would fully work. Here are some questions and comments I had when reading through this.

Questions

Comments

I still would like to explore if it’s possible to consolidate the steps so that the process of native checkout is similar. Could we do something like this?

dengjeffrey commented 7 years ago

Questions Answered

    private void handleGoogleWalletError(int errorCode) {
        switch (errorCode) {
            // Recoverable Wallet errors
            case WalletConstants.ERROR_CODE_SPENDING_LIMIT_EXCEEDED:
                Log.e(LOG_TAG, "Spending limit exceeded");
                Toast.makeText(this, "Spending Limit exceeded. Please adjust your cart and try again", Toast.LENGTH_SHORT).show();
                launchProductListActivity();
                break;

            // Unrecoverable Wallet errors
            case WalletConstants.ERROR_CODE_INVALID_PARAMETERS:
            case WalletConstants.ERROR_CODE_AUTHENTICATION_FAILURE:
            case WalletConstants.ERROR_CODE_BUYER_ACCOUNT_ERROR:
            case WalletConstants.ERROR_CODE_MERCHANT_ACCOUNT_ERROR:
            case WalletConstants.ERROR_CODE_SERVICE_UNAVAILABLE:
            case WalletConstants.ERROR_CODE_UNSUPPORTED_API_VERSION:
            case WalletConstants.ERROR_CODE_UNKNOWN:
            default:
                Log.e(LOG_TAG, "Unrecoverable wallet error:" + errorCode);
                Toast.makeText(this, "Could not complete the checkout, please try again later", Toast.LENGTH_SHORT).show();
                break;
        }
    }
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, completion: @escaping (PKPaymentAuthorizationStatus) -> Void) {
  let authStatus = ... status from performing checkout 
   completion(authStatus)
}

I'll also add some of these comments above. I think you're right making it consistent is probably less confusing for the implementor. @mikkoh

mikkoh commented 7 years ago

I'll try to read over this comment again and give detailed feedback. But I just want to bring up one thing.

we can fail on the call to checkout, which PKPaymentAuthorizationViewController will handle by displaying the status message to the user

I think it would be good if we still gave the Unity developer the ability to be reactive to a failure. For instance lets say in their game the main character reacts to users decision to not buy product. For instance lets say they shake their fist towards the screen when the user doesnt buy the stuff their selling. Or gives a thumbs up after the purchase experience if they did buy something.

In order to facilitate this we need to add a callback for failures and successes.

mikkoh commented 7 years ago

@dengjeffrey any updates on this? You mentioned today that @dbart01 had given you some new ideas on implementation.

dengjeffrey commented 7 years ago

@mikkoh I will have a more detailed write up before noon tomorrow

mikkoh commented 7 years ago

@dengjeffrey here are some updated questions and me just trying to think all of this through.

Let me know if this example usage makes sense:

if (Cart.CanCheckoutWithNativePay()) {
    Cart.PrepareNativeCheckout(key, (walletInfo) => {
        // what can I do with walletInfo ?!?

        Cart.CompleteNativeCheckout(onCompleteSucess, onCompleteFail);
    }, onPrepareFail);
} else if (Cart.CanShowNativePaySetup()) {
    Cart.ShowNativePaySetup(onNativePaySetupSuccess, onNativePaySetupFail);
}

All methods on Cart for native checkout:

Cart.CanCheckoutWithNativePay();
Cart.CanShowNativePaySetup();
Cart.ShowNativePaySetup();
Cart.PrepareNativeCheckout(key, onPrepareSuccess, onPrepareFail);
Cart.CompleteNativeCheckout(onCompleteSucess, onCompleteFail);
dengjeffrey commented 7 years ago

Below is what an example usage would look like.

if (Cart.CanCheckoutWithNativePay()) {
    // Display Pay with Android or Apple Pay button, alongside web checkout button
    #if UNITY_IOS
    ApplePayButton.Instantiate(onClick: () => {
        Cart.PrepareNativeCheckout(key, (walletInfo) => {
            // Do nothing with walletInfo, since it's null on Apple Pay
            Cart.CompleteNativeCheckout(onCompleteSucess, onCompleteFail);   
        }
    })
    #elif UNITY_ANDROID
    AndroidPayButton.Instantiate(onClick: () => {
        Cart.PrepareNativeCheckout(key, (walletInfo) => {
            // Display a confirmation UI to the user based on the info WalletInfo, the implementor has to make this UI
            // walletInfo contains the following: 
            // String[] paymentDescriptions (https://developers.google.com/android/reference/com/google/android/gms/wallet/MaskedWallet)
            // MailingAddress shippingAddress 
            WalletConfirmationUI.Instantiate(onConfirmClick: () => {
                Cart.CompleteNativeCheckout(onCompleteSucess, onCompleteFail);   
            })
        }
    })
    #else 
    // Show some Unity editor button for testing
    #endif
} else if (Cart.CanShowNativePaySetup()) {
   // Display a Setup Apple Pay button. 
   // This part might cause confusion for the implementor
   // whether or not the implementor will need a Button for Android, 
   // they will have to pay close attention to our Docs.
    Cart.ShowNativePaySetup(onNativePaySetupSuccess, onNativePaySetupFail);
}

In addition to the methods you have mentioned in Cart for native checkout, we need to include Update methods. So far for iOS:

void UpdateSummaryItemsForShippingIdentifier(string identifier)
void UpdateSummaryItemsForShippingAddress(string addressJSON)
void UpdateSummaryItemsForShippingContact(string contactJSON)
void UpdateSummaryItemsForPaymentMethod(string paymentMethodJSON)
// This one is called when a payment token is generated via Apple Pay and 
// Apple pay needs a server status whether the payment went through or not before dismissing
void FetchCheckoutStatusForToken(string token)

// Calls to Plugin
bool CanCheckoutWithNativePay() 
bool CanShowNativePaySetup()
void ShowNativePaySetup()
void PrepareNativeCheckoutWithKey(string key, Action<WalletInfo> success, Action<CheckoutFailureType, string> failure)
void CompleteNativeCheckout(Action success, Action<CheckoutFailureType, string> failure)