diff --git a/CHANGELOG.md b/CHANGELOG.md index f919c2161..586d02f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,14 @@ ### New features +- Added the `hasPairedAppleWatch` option to `canAddCardToWallet`. [#1219](https://github.com/stripe/stripe-react-native/pull/1219) + ## Fixes - Fixed an issue where builds would error with the message `'const' enums are not supported.` [see commit](https://github.com/stripe/stripe-react-native/commit/f882bfa588aa6d23a980b4b43d2cca660ca1dd2a) - Fixed an issue where the `canAddCardToWallet` method would sometimes wrongly return `false` with a `details.status` of `MISSING_CONFIGURATION` in production builds. [#1215](https://github.com/stripe/stripe-react-native/pull/1215) - Fixed an issue on Android where, for certain countries, the postal code would not be enabled but would still be required. [#1213](https://github.com/stripe/stripe-react-native/pull/1213) +- Fixed an issue on iOS where `canAddCardToWallet` would return `false` if the card had already been provisioned on a paired device like an Apple Watch, but had not yet been provisioned on the current device, and would also return `false` if the card had been provisioned on the current device, but not on a paired Apple Watch. [#1219](https://github.com/stripe/stripe-react-native/pull/1219) ## 0.21.0 - 2022-11-15 diff --git a/ios/PaymentPassFinder.swift b/ios/PaymentPassFinder.swift new file mode 100644 index 000000000..ff3c4b293 --- /dev/null +++ b/ios/PaymentPassFinder.swift @@ -0,0 +1,61 @@ +// +// PKPaymentPassFinder.swift +// stripe-react-native +// +// Created by Charles Cruzan on 10/6/22. +// + +import Foundation + +internal class PaymentPassFinder: NSObject { + enum PassLocation: String { + case CURRENT_DEVICE + case PAIRED_DEVICE + } + + class func findPassWithLast4(last4: String, hasPairedAppleWatch: Bool, completion: @escaping ((Bool, [PassLocation]) -> Void)) { + let existingPassOnDevice: PKPass? = { + if #available(iOS 13.4, *) { + return PKPassLibrary().passes(of: PKPassType.secureElement) + .first(where: { $0.secureElementPass?.primaryAccountNumberSuffix == last4 && $0.secureElementPass?.passActivationState != .deactivated && !$0.isRemotePass }) + } else { + return PKPassLibrary().passes(of: PKPassType.payment) + .first(where: { $0.paymentPass?.primaryAccountNumberSuffix == last4 && $0.paymentPass?.passActivationState != .deactivated && !$0.isRemotePass }) + } + }() + + var passLocations: [PassLocation] = [] + if (existingPassOnDevice != nil) { + passLocations.append(.CURRENT_DEVICE) + } + + // We're done here if the user does not have a paired Apple Watch + if (!hasPairedAppleWatch) { + completion( + passLocations.count < 1, + passLocations + ) + return + } + + let existingPassOnPairedDevices: PKPass? = { + if #available(iOS 13.4, *) { + return PKPassLibrary().remoteSecureElementPasses + .first(where: { $0.secureElementPass?.primaryAccountNumberSuffix == last4 && $0.secureElementPass?.passActivationState != .deactivated }) + } else { + return PKPassLibrary().remotePaymentPasses() + .first(where: { $0.paymentPass?.primaryAccountNumberSuffix == last4 && $0.paymentPass?.passActivationState != .deactivated }) + } + }() + + + if (existingPassOnPairedDevices != nil) { + passLocations.append(.PAIRED_DEVICE) + } + + completion( + passLocations.count < 2, + passLocations + ) + } +} diff --git a/ios/PushProvisioning/PushProvisioningUtils.swift b/ios/PushProvisioning/PushProvisioningUtils.swift index ea5569b76..02efc8db6 100644 --- a/ios/PushProvisioning/PushProvisioningUtils.swift +++ b/ios/PushProvisioning/PushProvisioningUtils.swift @@ -12,25 +12,33 @@ internal class PushProvisioningUtils { class func canAddCardToWallet( last4: String, primaryAccountIdentifier: String, - testEnv: Bool - ) -> (canAddCard: Bool, status: AddCardToWalletStatus?) { + testEnv: Bool, + hasPairedAppleWatch: Bool, + completion: @escaping (_ canAddCard: Bool, _ status: AddCardToWalletStatus?) -> Void + ) { if (!PKAddPassesViewController.canAddPasses()) { - return (false, AddCardToWalletStatus.UNSUPPORTED_DEVICE) + completion(false, AddCardToWalletStatus.UNSUPPORTED_DEVICE) } - var status : AddCardToWalletStatus? = nil - var canAddCard = PushProvisioningUtils.canAddPaymentPass( + let canAddCard = canAddPaymentPass( primaryAccountIdentifier: primaryAccountIdentifier, isTestMode: testEnv) if (!canAddCard) { - status = AddCardToWalletStatus.MISSING_CONFIGURATION - } else if (PushProvisioningUtils.passExistsWith(last4: last4)) { - canAddCard = false - status = AddCardToWalletStatus.CARD_ALREADY_EXISTS + completion(canAddCard, AddCardToWalletStatus.MISSING_CONFIGURATION) + } else { + PaymentPassFinder.findPassWithLast4(last4: last4, hasPairedAppleWatch: hasPairedAppleWatch) { canAddCardToADevice, passLocations in + var status: AddCardToWalletStatus? = nil + if (!canAddCardToADevice) { + status = AddCardToWalletStatus.CARD_ALREADY_EXISTS + } else if (passLocations.contains(.PAIRED_DEVICE)) { + status = AddCardToWalletStatus.CARD_EXISTS_ON_PAIRED_DEVICE + } else if (passLocations.contains(.CURRENT_DEVICE)) { + status = AddCardToWalletStatus.CARD_EXISTS_ON_CURRENT_DEVICE + } + completion(canAddCardToADevice, status) + } } - - return (canAddCard, status) } class func canAddPaymentPass(primaryAccountIdentifier: String, isTestMode: Bool) -> Bool { @@ -45,20 +53,35 @@ internal class PushProvisioningUtils { } } - class func passExistsWith(last4: String) -> Bool { - let existingPass: PKPass? = { + class func getPassLocation(last4: String) -> PaymentPassFinder.PassLocation? { + let existingPassOnDevice: PKPass? = { if #available(iOS 13.4, *) { - return PKPassLibrary().passes(of: PKPassType.secureElement).first(where: {$0.secureElementPass?.primaryAccountNumberSuffix == last4}) + return PKPassLibrary().passes(of: PKPassType.secureElement) + .first(where: { $0.secureElementPass?.primaryAccountNumberSuffix == last4 && $0.secureElementPass?.passActivationState != .suspended && !$0.isRemotePass }) } else { - return PKPassLibrary().passes(of: PKPassType.payment).first(where: {$0.paymentPass?.primaryAccountNumberSuffix == last4}) + return PKPassLibrary().passes(of: PKPassType.payment) + .first(where: { $0.paymentPass?.primaryAccountNumberSuffix == last4 && $0.paymentPass?.passActivationState != .suspended && !$0.isRemotePass }) } }() - return existingPass != nil + + let existingPassOnPairedDevices: PKPass? = { + if #available(iOS 13.4, *) { + return PKPassLibrary().remoteSecureElementPasses + .first(where: { $0.secureElementPass?.primaryAccountNumberSuffix == last4 && $0.secureElementPass?.passActivationState != .suspended }) + } else { + return PKPassLibrary().remotePaymentPasses() + .first(where: { $0.paymentPass?.primaryAccountNumberSuffix == last4 && $0.paymentPass?.passActivationState != .suspended }) + } + }() + + return existingPassOnDevice != nil ? PaymentPassFinder.PassLocation.CURRENT_DEVICE : (existingPassOnPairedDevices != nil ? PaymentPassFinder.PassLocation.PAIRED_DEVICE : nil) } enum AddCardToWalletStatus: String { case UNSUPPORTED_DEVICE case MISSING_CONFIGURATION case CARD_ALREADY_EXISTS + case CARD_EXISTS_ON_CURRENT_DEVICE + case CARD_EXISTS_ON_PAIRED_DEVICE } } diff --git a/ios/StripeSdk.swift b/ios/StripeSdk.swift index 708e1d069..77854836a 100644 --- a/ios/StripeSdk.swift +++ b/ios/StripeSdk.swift @@ -1116,13 +1116,17 @@ class StripeSdk: RCTEventEmitter, STPBankSelectionViewControllerDelegate, UIAdap resolve(Errors.createError(ErrorType.Failed, "You must provide `cardLastFour`")) return } - let (canAddCard, status) = PushProvisioningUtils.canAddCardToWallet(last4: last4, - primaryAccountIdentifier: params["primaryAccountIdentifier"] as? String ?? "", - testEnv: params["testEnv"] as? Bool ?? false) - resolve([ - "canAddCard": canAddCard, - "details": ["status": status?.rawValue], - ]) + PushProvisioningUtils.canAddCardToWallet( + last4: last4, + primaryAccountIdentifier: params["primaryAccountIdentifier"] as? String ?? "", + testEnv: params["testEnv"] as? Bool ?? false, + hasPairedAppleWatch: params["hasPairedAppleWatch"] as? Bool ?? false) + { canAddCard, status in + resolve([ + "canAddCard": canAddCard, + "details": ["status": status?.rawValue], + ]) + } } @objc(isCardInWallet:resolver:rejecter:) @@ -1135,7 +1139,7 @@ class StripeSdk: RCTEventEmitter, STPBankSelectionViewControllerDelegate, UIAdap resolve(Errors.createError(ErrorType.Failed, "You must provide `cardLastFour`")) return } - resolve(["isInWallet": PushProvisioningUtils.passExistsWith(last4: last4)]) + resolve(["isInWallet": PushProvisioningUtils.getPassLocation(last4: last4) != nil]) } @objc(collectBankAccountToken:resolver:rejecter:) diff --git a/ios/Tests/PushProvisioningTests.swift b/ios/Tests/PushProvisioningTests.swift index a95d06385..54630ac2f 100644 --- a/ios/Tests/PushProvisioningTests.swift +++ b/ios/Tests/PushProvisioningTests.swift @@ -11,19 +11,22 @@ import XCTest class PushProvisioningTests: XCTestCase { func testCanAddCardToWalletInTestMode() throws { - let (canAddCard, status) = PushProvisioningUtils.canAddCardToWallet(last4: "4242", + PushProvisioningUtils.canAddCardToWallet(last4: "4242", primaryAccountIdentifier: "", - testEnv: true) - XCTAssertEqual(canAddCard, true) - XCTAssertEqual(status, nil) + testEnv: true, hasPairedAppleWatch: false) { canAddCard, status in + XCTAssertEqual(canAddCard, true) + XCTAssertEqual(status, nil) + } } - + func testCanAddCardToWalletInLiveMode() throws { - let (canAddCard, status) = PushProvisioningUtils.canAddCardToWallet(last4: "4242", + PushProvisioningUtils.canAddCardToWallet(last4: "4242", primaryAccountIdentifier: "", - testEnv: false) - XCTAssertEqual(canAddCard, false) - XCTAssertEqual(status, PushProvisioningUtils.AddCardToWalletStatus.MISSING_CONFIGURATION) + testEnv: false, + hasPairedAppleWatch: false) { canAddCard, status in + XCTAssertEqual(canAddCard, false) + XCTAssertEqual(status, PushProvisioningUtils.AddCardToWalletStatus.MISSING_CONFIGURATION) + } } func testCanAddPaymentPassInTestMode() throws { @@ -42,8 +45,8 @@ class PushProvisioningTests: XCTestCase { func testCheckIfPassExists() throws { XCTAssertEqual( - PushProvisioningUtils.passExistsWith(last4: "4242"), - false + PushProvisioningUtils.getPassLocation(last4: "4242"), + nil ) } } diff --git a/package.json b/package.json index 0f553b3b4..e82c5245a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "bootstrap": "yarn example && yarn && yarn pods", "bootstrap-no-pods": "yarn example && yarn", "docs": "yarn typedoc ./src/index.tsx --out ./docs/api-reference --tsconfig ./tsconfig.json --readme none --sort source-order", - "run-example-ios": "cd example;ENVFILE=.env.ci react-native run-ios --configuration Release --simulator \"iPhone 13 (15.5)\"", + "run-example-ios": "cd example;ENVFILE=.env.ci react-native run-ios --configuration Release --simulator \"iPhone 13 (15.2)\"", "run-example-android": "cd example;ENVFILE=.env.ci react-native run-android --variant=release", "test:e2e:ios": "mkdir -p .tmp/screenshots && node ./run-appium-tests.js ios", "test:e2e:android": "mkdir -p .tmp/screenshots && node ./run-appium-tests.js android", diff --git a/src/types/PlatformPay.ts b/src/types/PlatformPay.ts index 2419fa9d1..f7610568f 100644 --- a/src/types/PlatformPay.ts +++ b/src/types/PlatformPay.ts @@ -20,14 +20,14 @@ export type ApplePaySheetError = message?: string; }; -export const enum ApplePaySheetErrorType { +export enum ApplePaySheetErrorType { InvalidShippingAddress = 'InvalidShippingAddress', UnserviceableShippingAddress = 'UnserviceableShippingAddress', InvalidCouponCode = 'InvalidCouponCode', ExpiredCouponCode = 'ExpiredCouponCode', } -export const enum ContactField { +export enum ContactField { EmailAddress = 'emailAddress', Name = 'name', PhoneNumber = 'phoneNumber', @@ -35,7 +35,7 @@ export const enum ContactField { PostalAddress = 'postalAddress', } -export const enum InvalidShippingField { +export enum InvalidShippingField { Street = 'street', City = 'city', SubAdministrativeArea = 'subAdministrativeArea', @@ -76,7 +76,7 @@ export type ApplePayPaymentMethodParams = { couponCode?: string; }; -export const enum ApplePayMerchantCapability { +export enum ApplePayMerchantCapability { /** Required. This value must be supplied. */ Supports3DS = 'supports3DS', /** Optional. If present, only transactions that are categorized as credit cards are allowed. */ @@ -86,7 +86,7 @@ export const enum ApplePayMerchantCapability { } /** A type that indicates how to ship purchased items. */ -export const enum ApplePayShippingType { +export enum ApplePayShippingType { /** Default. */ Shipping = 'shipping', Delivery = 'delivery', @@ -140,7 +140,7 @@ export type GooglePayPaymentMethodParams = { }; }; -export const enum BillingAddressFormat { +export enum BillingAddressFormat { /** Collect name, street address, locality, region, country code, and postal code. */ Full = 'FULL', /** Collect name, country code, and postal code (default). */ @@ -161,7 +161,7 @@ export type ConfirmParams = { applePay?: ApplePayBaseParams; }; -export const enum ButtonType { +export enum ButtonType { /** A button with the Apple Pay or Google Pay logo only, useful when an additional call to action isn't needed. */ Default = 0, /** A button useful for product purchases. */ @@ -203,7 +203,7 @@ export const enum ButtonType { } /** iOS only. */ -export const enum ButtonStyle { +export enum ButtonStyle { /** A white button with black lettering. */ White = 0, /** A white button with black lettering and a black outline. */ @@ -221,7 +221,7 @@ export type CartSummaryItem = | RecurringCartSummaryItem; /** iOS only. */ -export const enum PaymentType { +export enum PaymentType { Deferred = 'Deferred', Immediate = 'Immediate', Recurring = 'Recurring', @@ -261,7 +261,7 @@ export type RecurringCartSummaryItem = { }; /** iOS only. */ -export const enum IntervalUnit { +export enum IntervalUnit { Minute = 'minute', Hour = 'hour', Day = 'day', diff --git a/src/types/PushProvisioning.ts b/src/types/PushProvisioning.ts index a76f46044..3b6c12735 100644 --- a/src/types/PushProvisioning.ts +++ b/src/types/PushProvisioning.ts @@ -9,7 +9,7 @@ export type GooglePayCardToken = { status: GooglePayCardTokenStatus; }; -export const enum GooglePayCardTokenStatus { +export enum GooglePayCardTokenStatus { /** */ TOKEN_STATE_NEEDS_IDENTITY_VERIFICATION = 'TOKEN_STATE_NEEDS_IDENTITY_VERIFICATION', /** */ @@ -43,6 +43,8 @@ export type CanAddCardToWalletParams = { cardLastFour: string; /** iOS only. Set this to `true` until shipping through TestFlight || App Store. If true, you must be using live cards, and have the proper iOS entitlement set up. See https://stripe.com/docs/issuing/cards/digital-wallets?platform=react-native#requesting-access-for-ios */ testEnv?: boolean; + /** iOS only. Set this to `true` if: your user has an Apple Watch device currently paired, and you want to check that device for the presence of the specified card. */ + hasPairedAppleWatch?: boolean; }; export type CanAddCardToWalletResult = @@ -60,11 +62,15 @@ export type CanAddCardToWalletResult = error: StripeError; }; -export const enum CanAddCardToWalletStatus { +export enum CanAddCardToWalletStatus { /** You are missing configuration required for Push Provisioning. Make sure you've completed all steps at https://stripe.com/docs/issuing/cards/digital-wallets?platform=react-native. */ MISSING_CONFIGURATION = 'MISSING_CONFIGURATION', /** This device doesn't support adding a card to the native wallet. */ UNSUPPORTED_DEVICE = 'UNSUPPORTED_DEVICE', /** This card already exists on this device and any paired devices. */ CARD_ALREADY_EXISTS = 'CARD_ALREADY_EXISTS', + /** This card already exists on this device, but not on the paired device. */ + CARD_EXISTS_ON_CURRENT_DEVICE = 'CARD_EXISTS_ON_CURRENT_DEVICE', + /** This card already exists on the paired device, but not on this device. */ + CARD_EXISTS_ON_PAIRED_DEVICE = 'CARD_EXISTS_ON_PAIRED_DEVICE', } diff --git a/wdio.ios.js b/wdio.ios.js index 7993b5d31..4de00454b 100644 --- a/wdio.ios.js +++ b/wdio.ios.js @@ -38,7 +38,7 @@ exports.config = { browserName: '', appiumVersion: '1.22.2', platformName: 'iOS', - platformVersion: '15.5', + platformVersion: '15.2', deviceName: 'iPhone 13', app: 'example/ios/DerivedData/StripeSdkExample/Build/Products/Release-iphonesimulator/StripeSdkExample.app', automationName: 'XCUITest',