braintree / braintree-ios-drop-in

Braintree Drop-in for iOS
https://developers.braintreepayments.com
MIT License
98 stars 77 forks source link

mostRecentPaymentMethod callback fields are all null when fetching Apple Pay Card #383

Closed jbloodwhatnot closed 1 year ago

jbloodwhatnot commented 1 year ago

Integration Details (please complete the following information):

Describe the bug We are getting reports of users who have added apple pay cards that they are getting an error when paying for goods. After investigating the number of user reports we have verified that the users custom vault correctly has the apply pay cards added to their customer account. But the error is happening when we attempt to fetch the users most recent payment method. The response from the Braintree sdk callback has the error and result field as both as null. Which is causing the app to fail. Any idea what the issue could possibly be in order for both of these fields to return null.

Code snippet below:

BTDropInResult.mostRecentPaymentMethod(forClientToken: token) { res, error in
                guard error == nil else {
                    AppLogger.log(.error, message: "BRAINTREE FETCH ERROR", error: error)
                    continuation.resume(throwing: error!)
                    return
                }
                guard let res = res else {
                    AppLogger.log(.error, message: "BRAINTREE ERROR", error: PaymentError.noData)
                    continuation.resume(throwing: PaymentError.noData)
                    return
                }

                continuation.resume(returning: res)
            }

What we are seeing is the callback is returning both res and error field as nil. (Which I would have assumed one of these fields would always return non nil ? Any idea of what the cause for this could be.

From our logs it seems like its users using Apply Pay cards that are effected. It has started happening for our users for us since 31st October. I was able to reproduce locally but its hard to debug the issue as the SDK is not really giving any hints as to what has gone wrong here. I checked the users who had this issue and all had the Apple Cards correctly added to their paypal vaults/custom accounts.

To Reproduce Steps to reproduce the behavior:

  1. Go to '...'
  2. Click on '....'
  3. See error

Expected behavior Description of what you expected to happen.

mostRecentPaymentMethod should return the users Apple Pay card.

Screenshots N/A

jaxdesmarais commented 1 year ago

Hello @jbloodwhatnot -

There should not be a case where mostRecentPaymentMethod returns nil for both the result and error (source code called from the method you are calling here) so this behavior is certainly unexpected.

You mention that this is only happening in production and you have been able to reproduce in the sandbox environment, is my understanding correct? Are you able to send over a video or screenshot of what is meant by "getting an error when paying for goods"? Can you also provide the steps and environment used to reproduce the error locally?

I look forward to hearing back and troubleshooting further.

jbloodwhatnot commented 1 year ago

Hello @jbloodwhatnot -

There should not be a case where mostRecentPaymentMethod returns nil for both the result and error (source code called from the method you are calling here) so this behavior is certainly unexpected.

You mention that this is only happening in production and you have been able to reproduce in the sandbox environment, is my understanding correct? Are you able to send over a video or screenshot of what is meant by "getting an error when paying for goods"? Can you also provide the steps and environment used to reproduce the error locally?

I look forward to hearing back and troubleshooting further.

Sorry we can only reproduce this in production. We cannot reproduce in sandbox environment. It seems to be specifically Apply Pay cards that we are having issue with in production. All reports from our users are using Apple Pay cards. It works fine in sandbox environment using the simulated apple pay cards. But in production we can reproduce it in our app.

Steps to reproduce are.

  1. User adds card Apple Pay card to Braintree (through our app using the Braintree SDK)
  2. If we look up the user the card is visible on the Users Braintree customer account and the card details all look correct.
  3. In the app we call mostRecentPaymentMethod to fetch the users most recent card. So that they can pay for something in the app. But the issue is the method is returning null in both fields sadly.

I can't really give a local project to reproduce as its only happening on production for whatever reason.

scannillo commented 1 year ago

šŸ‘‹ Hi @jbloodwhatnot - thanks for the additional detail.

Can you confirm if you are talking about the Card (red) OR Apple Pay (blue) payment method type, as shown in the screenshot below?

Screen Shot 2022-12-02 at 9 29 27 AM

It sounds like you are talking about the Card (red) selection option above, and then entering an Apple Card into the Card Number text field?

Screen Shot 2022-12-02 at 9 31 46 AM
jbloodwhatnot commented 1 year ago

Hi @scannillo,

So in our case on the app the actual payment method is being setup on the server using the Braintree Python SDK. But the card that is being added is a card from the users Apple Pay wallet.

That cards being added via the backend are being stored correctly as I can see the cards in the Braintree Dashboard in the vault section on my Account.But for some reason the SDK is not returning these cards and the odd scenario I described of the fields being both null is happening.

So on the app the flow is. The app uses PKPaymentAuthorizationViewController as part of the Apply Pay api's to authorize a card that is stored in users apple Pay wallet. Using the card from this authorization we send the card to be stored to our backend server which is using the Braintree Python SDK to store the card. We can see the card in the dashboard. But SDK is not returning these Apple Pay Wallet cards for some reason.

scannillo commented 1 year ago

šŸ‘‹ Hi again @jbloodwhatnot - thanks for providing more details, however we still need more information in order to reproduce.

Can you provide code snippets for how you're vaulting the Apple Pay payment method using the Braintree Python SDK? If you can also provide relevant client-side code snippets for how you're tokenizing Apple Pay, that would be helpful too.

jbloodwhatnot commented 1 year ago

šŸ‘‹ Hi again @jbloodwhatnot - thanks for providing more details, however we still need more information in order to reproduce.

Can you provide code snippets for how you're vaulting the Apple Pay payment method using the Braintree Python SDK? If you can also provide relevant client-side code snippets for how you're tokenizing Apple Pay, that would be helpful too.

Hi there is a decent amount of code here. But tried to distill it down to focus on the flow on iOS side of the Apply Pay Card being added and also on the python side.


public class PaymentManager: NSObject {
    fileprivate static let shared: PaymentManager = PaymentManager()

    var completionHandler: ((PaymentSelectionResult) -> Void)?

    private override init() {}

    static func braintreeClient() async throws -> BTAPIClient {
        // fetchPaymentToken() is calling to our backend to fetch e.g as described here. https://developer.paypal.com/braintree/docs/start/hello-server#generate-a-client-token
        let token = try await fetchPaymentToken()
        guard let client = BTAPIClient(authorization: token) else {
            AppLogger.log(.error, message: "BRAINTREE API AUTH ERROR", error: PaymentError.braintreeNoAPIClient)
            throw PaymentError.braintreeNoAPIClient
        }
        return client
    }

// set up PKPaymentRequest of 0.00 for authorization
func newPaymentRequest() async throws -> PKPaymentRequest {
        let client = try await Self.braintreeClient()
        let applePayClient = BTApplePayClient(apiClient: client)
        return try await withCheckedThrowingContinuation { continuation in
            applePayClient.paymentRequest { request, error in
                guard error == nil else {
                    AppLogger.log(.error, message: "BRAINTREE APPLEPAY ERROR", error: error!)
                    continuation.resume(throwing: error!)
                    return
                }
                guard let request = request else {
                    AppLogger.log(.error, message: "BRAINTREE APPLEPAY ERROR", error: PaymentError.braintreeNoResult)
                    continuation.resume(throwing: PaymentError.braintreeNoResult)
                    return
                }
                request.requiredBillingContactFields = [.name, .postalAddress]
                request.merchantCapabilities = .capability3DS
                continuation.resume(returning: request)
            }
        }
    }

    static func paymentRequestForAuthorization() async throws -> PKPaymentRequest {
        let request = try await newPaymentRequest()
        request.paymentSummaryItems = [
            .init(label: "Payment Authorization", amount: 0.00),
        ]
        return request
    }

// Setup apple pay card

 func setupApplePay(completion: ((PaymentSelectionResult) -> Void)? = nil) {
        Task {
            do {
                if let completion = completion {
                    PaymentManager.shared.completionHandler = completion
                }
                let req = try await PaymentManager.paymentRequestForAuthorization()
                await presentApplePaySetupAuthorization(req)
            } catch {
                displayError(error: error)
            }
        }
    }

    @MainActor func presentApplePaySetupAuthorization(_ request: PKPaymentRequest) {
        guard let vc = PKPaymentAuthorizationViewController(paymentRequest: request) else { return }
        vc.delegate = PaymentManager.shared
        present(vc, animated: true, completion: nil)
    }

 // handle the sucess result from presenting the PKPaymentAuthorizationViewController
   public func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment) async -> PKPaymentAuthorizationResult {
        do {
            let result = try await handleApplePayPayment(payment)
            // calling our backend here to save the card server side e.g using python sdk code is included down lower
            try await PaymentService.savePaymentMethodString(result.encodedPaymentMethod)
            handleAuthorizedPaymentResult(.success(result.paymentModel))
            return .init(status: .success, errors: [])
        } catch {
            AppLogger.log(error: error)
            handleAuthorizedPaymentResult(.failure(error))
            return .init(status: .failure, errors: [error])
        }
    }

    private func handleAuthorizedPaymentResult(_ result: Result<BINPaymentModel, Error>) {
        if let completionHandler = completionHandler {
            if case let .failure(error) = result {
                completionHandler(.error(error))
            } else {
                completionHandler(.updated)
            }
        }
        completionHandler = nil
    }

    func handleApplePayPayment(_ payment: PKPayment) async throws -> ApplePayResult {
        let client = try await Self.braintreeClient()
        let applePayClient = BTApplePayClient(apiClient: client)
        let nonce = try await applePayClient.tokenizeApplePay(payment)
        let cd = CardDescription(
            nonce: nonce.nonce,
            type: "\(BTDropInPaymentMethodType.applePay.rawValue)",
            description: "Apple Pay"
        )
        //cd.jsonString just convertering CardDescription object into json representation
        guard let token = cd.jsonString else {
            throw PaymentError.noData
        }
        let paymentModel = BINPaymentModel(cardToken: token, last4: "", gateway: .braintree, isApplePay: true)
        return ApplePayResult(paymentModel: paymentModel, encodedPaymentMethod: token)
    }

}

public struct CardDescription: Codable {
    let nonce: String?
    let type: String
    let description: String
    var deviceData: String = ""

    public init(nonce: String?, type: String, description: String, deviceData: String = "") {
        self.nonce = nonce
        self.type = type
        self.description = description
        self.deviceData = deviceData
    }
}

payment.py
// Python code for storing card from graphql api call
@mutation.field("createPaymentMethod")
@convert_kwargs_to_snake_case
def create_payment_method_mutation(obj, info, **kwargs):
    user = info.context.viewer_context.get_user()

    pd = kwargs.get("payment_data", "{}")
    payment_data = json.loads(pd) if not isinstance(pd, dict) else pd

    pm = BraintreePaymentMethodCreator(db_session=db.session, user=user)

    card = pm.create(payment_data, billing_address=None, default=True)

    return {"payment_method": card} 

class BraintreePaymentMethodCreator(BaseUsPaymentCommand):
    def create(self, payment_data: dict, **kwargs):
        from whatnot.commands.payment import BrainTreeCardStorage

        bt = BrainTree(self.user)

        nonce = payment_data.get("nonce")
        device_data = payment_data.get("payment_data")
        default = kwargs.get("default", False)
        billing_address = kwargs.get("billing_address") or self.user.billing_address
        payment_method = bt.get_or_create_payment(
            nonce,
            device_data,
            billing_address=billing_address,
        )

        // card is stored in our own database but ommited for brevity
        card = storeInDb(payment_method)
        return card

class BrainTree:
    def __init__(self, user=None):
        from flask import current_app

        self.session = db.session
        self.user = user
        production = current_app.config.get("BRAINTREE_PRODUCTION_ENABLED")
        self.gateway = braintree.BraintreeGateway(
            braintree.Configuration(
                braintree.Environment.Sandbox
                if not production
                else braintree.Environment.Production,
                merchant_id=current_app.config.get("BRAINTREE_MERCHANT_ID"),
                public_key=current_app.config.get("BRAINTREE_PUBLIC_KEY"),
                private_key=current_app.config.get("BRAINTREE_PRIVATE_KEY"),
            )
        )

        def get_or_create_payment(
        self, nonce, device_data=None, default=False, billing_address=None
    ):
        account_id = (
            self.user.braintree_account_id
            if self.user.braintree_account_id
            else self.get_customer().id
        )

        parameters = {
            "customer_id": str(account_id),
            "payment_method_nonce": nonce,
            "device_data": device_data,
            "options": {
                "make_default": default,
                "verify_card": True,
            },
        }

        if billing_address is not None:
            parameters["billing_address"] = {
                "first_name": billing_address.first_name,
                "last_name": billing_address.last_name,
                "street_address": billing_address.line1,
                "extended_address": billing_address.line2,
                "region": billing_address.state,
                "postal_code": billing_address.postal_code,
                "country_code_alpha2": billing_address.country_code,
            }
        result = self.gateway.payment_method.create(parameters)

        if result.is_success:
            return result.payment_method

        if result.credit_card_verification is not None:
            raise Exception(result.credit_card_verification.status)
        else:
            raise Exception(
                result.message
                or "Failed to validate Credit Card. Please try a different one"
            )

Screenshot of the added cards when looking at my account in the braintree dashboard. Have redacted most of the fields. But the cards appear fine here. They just don't return in the SDK.

vault

jbloodwhatnot commented 1 year ago

Howdy, Any updates on this or any luck in reproducing ? Or more info I can provide ?

scannillo commented 1 year ago

šŸ‘‹ Hi @jbloodwhatnot - so, to confirm, you are calling the Python SDKs create method with the nonce and customerID for an Apple Pay payment?

Are you having this issue with any other payment methods (ex: Card, PayPal, Venmo)?

scannillo commented 1 year ago

If you want to check out the branch gh-issue-383, I pushed up this commit, which might resolve the issue you were seeing of a (nil, nil) completion. Can you let me know if that branch solves that part of the issue?

jbloodwhatnot commented 1 year ago

šŸ‘‹ Hi @jbloodwhatnot - so, to confirm, you are calling the Python SDKs create method with the nonce and customerID for an Apple Pay payment?

Are you having this issue with any other payment methods (ex: Card, PayPal, Venmo)?

Hi, Yes this is correct. Also seems to just be specific to apple pay for us as far as I can tell. Other methods seem fine.

If you want to check out the branch gh-issue-383, I pushed up this commit, which might resolve the issue you were seeing of a (nil, nil) completion. Can you let me know if that branch solves that part of the issue?

Just back from holidays. Will give this a test in the new couple of days and let you know if it resolves. Thanks

jbloodwhatnot commented 1 year ago

If you want to check out the branch gh-issue-383, I pushed up this commit, which might resolve the issue you were seeing of a (nil, nil) completion. Can you let me know if that branch solves that part of the issue?

Tested this branch out now. I am now correctly getting the error field populated with your change that you made. The error returned is "No recent payment methods found." But still not clear why their are no recent payment methods. As here is my vault for my customer id. Have blurred out most fields to be on safe side. (Note I have multiple apple pay cards as its me adding my same card multiple times while testing this issue out) vault

jaxdesmarais commented 1 year ago

Hello @jbloodwhatnot -

The fix for the (nil, nil) completion was just released in version 9.8.0 of the Drop-in SDK.

Regarding the error you are seeing, we will likely need our internal team to look into more specifics of your account to troubleshoot further. Would you mind reaching out to our Support team via the help form and provide them with details of your gateway account for them to investigate further? Feel free to link them to this Github issues as well as we can provide them with details internally to help troubleshoot further.

scannillo commented 1 year ago

Hi @jbloodwhatnot - we were able to replicate the behavior you're seeing with our Support team. We will keep you posted.

scannillo commented 1 year ago

šŸ‘‹ Hi @jbloodwhatnot - so working together with Support, we learned a few things.

The behavior you're seeing is expected. ~3 months ago there was a change made, that essentially acted as a bug-fix to this functionality since it was not working as expected prior.

In our docs, we state that vaulting Apple Pay cards is only for recurring billing transactions and merchant-initiated transactions, not customer-initiated transactions. Apparently the documentation was always accurate, but the backend APIs were allowing non-recurring billing/customer-initiated Apple Pay transactions to vault. So a "fix" was made, but unfortunately nothing was communicated out to the docs since the documentation was accurate.

For more information details on how you can utilize recurring billing, please reach back out to Braintree Support.