diff --git a/Example/PaymentSheet Example/PaymentSheet Example/EmbeddedPlaygroundViewController.swift b/Example/PaymentSheet Example/PaymentSheet Example/EmbeddedPlaygroundViewController.swift index 378131fcdd3..ffc8e04d3a8 100644 --- a/Example/PaymentSheet Example/PaymentSheet Example/EmbeddedPlaygroundViewController.swift +++ b/Example/PaymentSheet Example/PaymentSheet Example/EmbeddedPlaygroundViewController.swift @@ -33,7 +33,7 @@ class EmbeddedPlaygroundViewController: UIViewController { checkoutButton.translatesAutoresizingMaskIntoConstraints = false return checkoutButton }() - + private let paymentOptionView = EmbeddedPaymentOptionView() init(configuration: EmbeddedPaymentElement.Configuration, intentConfig: PaymentSheet.IntentConfiguration, appearance: PaymentSheet.Appearance) { @@ -94,7 +94,7 @@ class EmbeddedPlaygroundViewController: UIViewController { checkoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), checkoutButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), ]) - + paymentOptionView.configure(with: embeddedPaymentElement.paymentOption, showMandate: !configuration.embeddedViewDisplaysMandateText) } @@ -123,15 +123,14 @@ extension EmbeddedPlaygroundViewController: EmbeddedPaymentElementDelegate { self.view.setNeedsLayout() self.view.layoutIfNeeded() } - + func embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: EmbeddedPaymentElement) { paymentOptionView.configure(with: embeddedPaymentElement.paymentOption, showMandate: !configuration.embeddedViewDisplaysMandateText) } } - private class EmbeddedPaymentOptionView: UIView { - + private let titleLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .body) @@ -140,14 +139,14 @@ private class EmbeddedPaymentOptionView: UIView { label.text = "Selected payment method" return label }() - + private let imageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() - + private let label: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .subheadline) @@ -155,7 +154,7 @@ private class EmbeddedPaymentOptionView: UIView { label.translatesAutoresizingMaskIntoConstraints = false return label }() - + private let mandateTextLabel: UILabel = { let mandateLabel = UILabel() mandateLabel.font = .preferredFont(forTextStyle: .footnote) @@ -165,45 +164,45 @@ private class EmbeddedPaymentOptionView: UIView { mandateLabel.textAlignment = .left return mandateLabel }() - + override init(frame: CGRect) { super.init(frame: frame) setupView() } - + required init?(coder: NSCoder) { super.init(coder: coder) setupView() } - + private func setupView() { addSubview(titleLabel) addSubview(imageView) addSubview(label) addSubview(mandateTextLabel) - + NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 15), titleLabel.topAnchor.constraint(equalTo: self.topAnchor), titleLabel.widthAnchor.constraint(equalTo: self.widthAnchor), titleLabel.heightAnchor.constraint(equalToConstant: 25), - + imageView.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), imageView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20), imageView.widthAnchor.constraint(equalToConstant: 25), imageView.heightAnchor.constraint(equalToConstant: 25), - + label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 12), label.trailingAnchor.constraint(equalTo: self.trailingAnchor), label.topAnchor.constraint(equalTo: self.topAnchor), label.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), - + mandateTextLabel.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), mandateTextLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12), mandateTextLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 12), ]) } - + func configure(with data: EmbeddedPaymentElement.PaymentOptionDisplayData?, showMandate: Bool) { titleLabel.isHidden = data == nil imageView.image = data?.image @@ -212,4 +211,3 @@ private class EmbeddedPaymentOptionView: UIView { mandateTextLabel.isHidden = !showMandate } } - diff --git a/Stripe/StripeiOSTests/PayWithLinkButtonSnapshotTests.swift b/Stripe/StripeiOSTests/PayWithLinkButtonSnapshotTests.swift index 7792c569d3c..2b5cb94c0a8 100644 --- a/Stripe/StripeiOSTests/PayWithLinkButtonSnapshotTests.swift +++ b/Stripe/StripeiOSTests/PayWithLinkButtonSnapshotTests.swift @@ -86,12 +86,16 @@ extension PayWithLinkButtonSnapshotTests { fileprivate struct LinkAccountStub: PaymentSheetLinkAccountInfoProtocol { let email: String let isRegistered: Bool + var redactedPhoneNumber: String? + var isLoggedIn: Bool } fileprivate func makeAccountStub(email: String, isRegistered: Bool) -> LinkAccountStub { return LinkAccountStub( email: email, - isRegistered: isRegistered + isRegistered: isRegistered, + redactedPhoneNumber: "+1********55", + isLoggedIn: true ) } diff --git a/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift index 6e0d0d4233f..bcc4d84a402 100644 --- a/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift +++ b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift @@ -135,6 +135,13 @@ import Foundation case linkPopupError = "link.popup.error" case linkPopupLogout = "link.popup.logout" + // MARK: - Link 2FA + case link2FAStart = "link.2fa.start" + case link2FAStartFailure = "link.2fa.start_failure" + case link2FAComplete = "link.2fa.complete" + case link2FACancel = "link.2fa.cancel" + case link2FAFailure = "link.2fa.failure" + // MARK: - Link Misc case linkAccountLookupComplete = "link.account_lookup.complete" case linkAccountLookupFailure = "link.account_lookup.failure" diff --git a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj index 9034b6d97aa..5e574f04ab8 100644 --- a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj +++ b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj @@ -54,10 +54,61 @@ 2EDF4115FDC40A5B0672CCFD /* Locale+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5DCF73BEC0CC35D4CE30361 /* Locale+Link.swift */; }; 311AC53D6C76953E9B70148A /* ConsumerSession+PublishableKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D61B52BFA201D25E8F6428 /* ConsumerSession+PublishableKey.swift */; }; 313F5F832B0BE5FD00BD98A9 /* Docs.docc in Sources */ = {isa = PBXBuildFile; fileRef = 313F5F822B0BE5FD00BD98A9 /* Docs.docc */; }; + 3147CEBB2CC07E960067B5E4 /* LinkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */; }; + 3147CEC02CC080570067B5E4 /* LinkInMemoryCookieStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBD2CC080570067B5E4 /* LinkInMemoryCookieStore.swift */; }; + 3147CEC12CC080570067B5E4 /* LinkCookieStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBC2CC080570067B5E4 /* LinkCookieStore.swift */; }; + 3147CEC22CC080570067B5E4 /* LinkSecureCookieStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBE2CC080570067B5E4 /* LinkSecureCookieStore.swift */; }; + 3147CECA2CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC32CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift */; }; + 3147CECB2CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC62CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift */; }; + 3147CECC2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC52CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift */; }; + 3147CECD2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC72CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift */; }; + 3147CECE2CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC82CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift */; }; + 3147CECF2CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEC42CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift */; }; + 3147CED12CC1BF6E0067B5E4 /* LinkCardEditElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED02CC1BF6E0067B5E4 /* LinkCardEditElement.swift */; }; + 3147CED82CC1BF860067B5E4 /* LinkVerificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED62CC1BF860067B5E4 /* LinkVerificationViewController.swift */; }; + 3147CED92CC1BF860067B5E4 /* LinkVerificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED22CC1BF860067B5E4 /* LinkVerificationController.swift */; }; + 3147CEDA2CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED72CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift */; }; + 3147CEDB2CC1BF860067B5E4 /* LinkVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED32CC1BF860067B5E4 /* LinkVerificationView.swift */; }; + 3147CEDC2CC1BF860067B5E4 /* LinkVerificationView-Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED42CC1BF860067B5E4 /* LinkVerificationView-Header.swift */; }; + 3147CEDD2CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CED52CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift */; }; + 3147CEE12CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEDE2CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift */; }; + 3147CEE22CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEDF2CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift */; }; + 3147CEE32CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE02CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift */; }; + 3147CEEE2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEED2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewModel.swift */; }; + 3147CEEF2CC1D3700067B5E4 /* PayWithLinkViewController-NewPaymentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE72CC1D3700067B5E4 /* PayWithLinkViewController-NewPaymentViewController.swift */; }; + 3147CEF02CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEEC2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewController.swift */; }; + 3147CEF12CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE92CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewModel.swift */; }; + 3147CEF22CC1D3700067B5E4 /* PayWithLinkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE42CC1D3700067B5E4 /* PayWithLinkViewController.swift */; }; + 3147CEF32CC1D3700067B5E4 /* PayWithLinkViewController-LoaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE62CC1D3700067B5E4 /* PayWithLinkViewController-LoaderViewController.swift */; }; + 3147CEF42CC1D3700067B5E4 /* PayWithLinkViewController-BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE52CC1D3700067B5E4 /* PayWithLinkViewController-BaseViewController.swift */; }; + 3147CEF52CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEE82CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewController.swift */; }; + 3147CEF62CC1D3700067B5E4 /* PayWithLinkViewController-UpdatePaymentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEEA2CC1D3700067B5E4 /* PayWithLinkViewController-UpdatePaymentViewController.swift */; }; + 3147CEF72CC1D3700067B5E4 /* PayWithLinkViewController-VerifyAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEEB2CC1D3700067B5E4 /* PayWithLinkViewController-VerifyAccountViewController.swift */; }; + 3147CEFA2CC1D3910067B5E4 /* LinkPaymentController-New.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEF82CC1D3910067B5E4 /* LinkPaymentController-New.swift */; }; + 3147CEFB2CC1D3910067B5E4 /* PayWithLinkController-New.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEF92CC1D3910067B5E4 /* PayWithLinkController-New.swift */; }; + 3147CEFE2CC1D3A60067B5E4 /* PayWithLinkViewController-WalletViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEFD2CC1D3A60067B5E4 /* PayWithLinkViewController-WalletViewModelTests.swift */; }; + 3147CEFF2CC1D3A60067B5E4 /* LinkInlineSignupElementSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEFC2CC1D3A60067B5E4 /* LinkInlineSignupElementSnapshotTests.swift */; }; + 3147CF012CC1D4350067B5E4 /* PaymentSheet-Configuration+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CF002CC1D4350067B5E4 /* PaymentSheet-Configuration+Link.swift */; }; 31699A812BE183B30048677F /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31699A802BE183B30048677F /* DownloadManager.swift */; }; 31699A832BE183D40048677F /* DownloadManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31699A822BE183D40048677F /* DownloadManagerTest.swift */; }; 316B33122B5F171C0008D2E5 /* UserDefaults+StripePaymentSheetTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316B33112B5F171C0008D2E5 /* UserDefaults+StripePaymentSheetTest.swift */; }; 31AD3BE72B0C2D080080C800 /* UIApplication+StripePaymentSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31AD3BE62B0C2D080080C800 /* UIApplication+StripePaymentSheet.swift */; }; + 31CC9B792CB5F69600E84A38 /* LinkNavigationBarSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B712CB5F69600E84A38 /* LinkNavigationBarSnapshotTests.swift */; }; + 31CC9B7D2CB5F69600E84A38 /* ButtonLinkSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B6C2CB5F69600E84A38 /* ButtonLinkSnapshotTests.swift */; }; + 31CC9B7E2CB5F69600E84A38 /* LinkNoticeViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B722CB5F69600E84A38 /* LinkNoticeViewSnapshotTests.swift */; }; + 31CC9B7F2CB5F69600E84A38 /* LinkPopupURLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B742CB5F69600E84A38 /* LinkPopupURLParserTests.swift */; }; + 31CC9B802CB5F69600E84A38 /* LinkToastSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B752CB5F69600E84A38 /* LinkToastSnapshotTests.swift */; }; + 31CC9B812CB5F69600E84A38 /* LinkInstantDebitMandateViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B702CB5F69600E84A38 /* LinkInstantDebitMandateViewSnapshotTests.swift */; }; + 31CC9B822CB5F69600E84A38 /* LinkURLGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B762CB5F69600E84A38 /* LinkURLGeneratorTests.swift */; }; + 31CC9B852CB5F69600E84A38 /* LinkBadgeViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B6D2CB5F69600E84A38 /* LinkBadgeViewSnapshotTest.swift */; }; + 31CC9B972CB5F74A00E84A38 /* LinkNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B882CB5F74A00E84A38 /* LinkNavigationBar.swift */; }; + 31CC9B982CB5F74A00E84A38 /* LinkNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B8A2CB5F74A00E84A38 /* LinkNoticeView.swift */; }; + 31CC9B9A2CB5F74A00E84A38 /* LinkToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B932CB5F74A00E84A38 /* LinkToast.swift */; }; + 31CC9B9C2CB5F74A00E84A38 /* LinkBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9B862CB5F74A00E84A38 /* LinkBadgeView.swift */; }; + 31CC9BA12CB5F93100E84A38 /* Button+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9BA02CB5F93100E84A38 /* Button+Link.swift */; }; + 31CC9BA42CB5F9D400E84A38 /* LinkKeyboardAvoidingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9BA32CB5F9D400E84A38 /* LinkKeyboardAvoidingScrollView.swift */; }; + 31CC9BA52CB5F9D400E84A38 /* LinkInstantDebitMandateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9BA22CB5F9D400E84A38 /* LinkInstantDebitMandateView.swift */; }; + 31CC9BA72CB610A300E84A38 /* ConfirmButton+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC9BA62CB610A300E84A38 /* ConfirmButton+Link.swift */; }; 31CDFC362BA8E66200B3DD91 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 31CDFC352BA8E66200B3DD91 /* PrivacyInfo.xcprivacy */; }; 335A19D93A5979557DB4CA4D /* PaymentMethodElementWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5012364ED0F2EEC6EC2AB52 /* PaymentMethodElementWrapper.swift */; }; 34CF08CBC636F596B8BA4C12 /* TextFieldElement+CardTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00C7F5905759525C9BF8BD4 /* TextFieldElement+CardTest.swift */; }; @@ -404,11 +455,62 @@ 2E2B99961C09E31383C9FCE9 /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; 2E42F31D392C0AED757D6239 /* StripePaymentSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePaymentSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 313F5F822B0BE5FD00BD98A9 /* Docs.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Docs.docc; sourceTree = ""; }; + 3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkUtils.swift; sourceTree = ""; }; + 3147CEBC2CC080570067B5E4 /* LinkCookieStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkCookieStore.swift; sourceTree = ""; }; + 3147CEBD2CC080570067B5E4 /* LinkInMemoryCookieStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkInMemoryCookieStore.swift; sourceTree = ""; }; + 3147CEBE2CC080570067B5E4 /* LinkSecureCookieStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkSecureCookieStore.swift; sourceTree = ""; }; + 3147CEC32CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPaymentMethodPicker.swift; sourceTree = ""; }; + 3147CEC42CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-AddButton.swift"; sourceTree = ""; }; + 3147CEC52CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-Cell.swift"; sourceTree = ""; }; + 3147CEC62CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-CellContentView.swift"; sourceTree = ""; }; + 3147CEC72CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-Header.swift"; sourceTree = ""; }; + 3147CEC82CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentMethodPicker-RadioButton.swift"; sourceTree = ""; }; + 3147CED02CC1BF6E0067B5E4 /* LinkCardEditElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkCardEditElement.swift; sourceTree = ""; }; + 3147CED22CC1BF860067B5E4 /* LinkVerificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationController.swift; sourceTree = ""; }; + 3147CED32CC1BF860067B5E4 /* LinkVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationView.swift; sourceTree = ""; }; + 3147CED42CC1BF860067B5E4 /* LinkVerificationView-Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkVerificationView-Header.swift"; sourceTree = ""; }; + 3147CED52CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkVerificationView-LogoutView.swift"; sourceTree = ""; }; + 3147CED62CC1BF860067B5E4 /* LinkVerificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationViewController.swift; sourceTree = ""; }; + 3147CED72CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkVerificationViewController-PresentationController.swift"; sourceTree = ""; }; + 3147CEDE2CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkCardEditElementSnapshotTests.swift; sourceTree = ""; }; + 3147CEDF2CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPaymentMethodPickerSnapshotTests.swift; sourceTree = ""; }; + 3147CEE02CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkVerificationViewSnapshotTests.swift; sourceTree = ""; }; + 3147CEE42CC1D3700067B5E4 /* PayWithLinkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayWithLinkViewController.swift; sourceTree = ""; }; + 3147CEE52CC1D3700067B5E4 /* PayWithLinkViewController-BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-BaseViewController.swift"; sourceTree = ""; }; + 3147CEE62CC1D3700067B5E4 /* PayWithLinkViewController-LoaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-LoaderViewController.swift"; sourceTree = ""; }; + 3147CEE72CC1D3700067B5E4 /* PayWithLinkViewController-NewPaymentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-NewPaymentViewController.swift"; sourceTree = ""; }; + 3147CEE82CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-SignUpViewController.swift"; sourceTree = ""; }; + 3147CEE92CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-SignUpViewModel.swift"; sourceTree = ""; }; + 3147CEEA2CC1D3700067B5E4 /* PayWithLinkViewController-UpdatePaymentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-UpdatePaymentViewController.swift"; sourceTree = ""; }; + 3147CEEB2CC1D3700067B5E4 /* PayWithLinkViewController-VerifyAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-VerifyAccountViewController.swift"; sourceTree = ""; }; + 3147CEEC2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-WalletViewController.swift"; sourceTree = ""; }; + 3147CEED2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-WalletViewModel.swift"; sourceTree = ""; }; + 3147CEF82CC1D3910067B5E4 /* LinkPaymentController-New.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPaymentController-New.swift"; sourceTree = ""; }; + 3147CEF92CC1D3910067B5E4 /* PayWithLinkController-New.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkController-New.swift"; sourceTree = ""; }; + 3147CEFC2CC1D3A60067B5E4 /* LinkInlineSignupElementSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkInlineSignupElementSnapshotTests.swift; sourceTree = ""; }; + 3147CEFD2CC1D3A60067B5E4 /* PayWithLinkViewController-WalletViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-WalletViewModelTests.swift"; sourceTree = ""; }; + 3147CF002CC1D4350067B5E4 /* PaymentSheet-Configuration+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentSheet-Configuration+Link.swift"; sourceTree = ""; }; 3168698F2C61B0F5EC1240FE /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; 31699A802BE183B30048677F /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 31699A822BE183D40048677F /* DownloadManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerTest.swift; sourceTree = ""; }; 316B33112B5F171C0008D2E5 /* UserDefaults+StripePaymentSheetTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+StripePaymentSheetTest.swift"; sourceTree = ""; }; 31AD3BE62B0C2D080080C800 /* UIApplication+StripePaymentSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "UIApplication+StripePaymentSheet.swift"; path = "StripePaymentSheet/Source/Categories/UIApplication+StripePaymentSheet.swift"; sourceTree = ""; }; + 31CC9B6C2CB5F69600E84A38 /* ButtonLinkSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonLinkSnapshotTests.swift; sourceTree = ""; }; + 31CC9B6D2CB5F69600E84A38 /* LinkBadgeViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkBadgeViewSnapshotTest.swift; sourceTree = ""; }; + 31CC9B702CB5F69600E84A38 /* LinkInstantDebitMandateViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkInstantDebitMandateViewSnapshotTests.swift; sourceTree = ""; }; + 31CC9B712CB5F69600E84A38 /* LinkNavigationBarSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNavigationBarSnapshotTests.swift; sourceTree = ""; }; + 31CC9B722CB5F69600E84A38 /* LinkNoticeViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNoticeViewSnapshotTests.swift; sourceTree = ""; }; + 31CC9B742CB5F69600E84A38 /* LinkPopupURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPopupURLParserTests.swift; sourceTree = ""; }; + 31CC9B752CB5F69600E84A38 /* LinkToastSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkToastSnapshotTests.swift; sourceTree = ""; }; + 31CC9B762CB5F69600E84A38 /* LinkURLGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkURLGeneratorTests.swift; sourceTree = ""; }; + 31CC9B862CB5F74A00E84A38 /* LinkBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkBadgeView.swift; sourceTree = ""; }; + 31CC9B882CB5F74A00E84A38 /* LinkNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNavigationBar.swift; sourceTree = ""; }; + 31CC9B8A2CB5F74A00E84A38 /* LinkNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNoticeView.swift; sourceTree = ""; }; + 31CC9B932CB5F74A00E84A38 /* LinkToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkToast.swift; sourceTree = ""; }; + 31CC9BA02CB5F93100E84A38 /* Button+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Link.swift"; sourceTree = ""; }; + 31CC9BA22CB5F9D400E84A38 /* LinkInstantDebitMandateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkInstantDebitMandateView.swift; sourceTree = ""; }; + 31CC9BA32CB5F9D400E84A38 /* LinkKeyboardAvoidingScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkKeyboardAvoidingScrollView.swift; sourceTree = ""; }; + 31CC9BA62CB610A300E84A38 /* ConfirmButton+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfirmButton+Link.swift"; sourceTree = ""; }; 31CDFC352BA8E66200B3DD91 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 32332E0DB0AE12377EBDDEF1 /* PaymentSheetIntentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentSheetIntentConfiguration.swift; sourceTree = ""; }; 32BDC53A88FB17F378C6B413 /* CardSectionWithScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardSectionWithScannerView.swift; sourceTree = ""; }; @@ -786,6 +888,29 @@ path = AddressViewController; sourceTree = ""; }; + 3147CEBF2CC080570067B5E4 /* CookieStore */ = { + isa = PBXGroup; + children = ( + 3147CEBC2CC080570067B5E4 /* LinkCookieStore.swift */, + 3147CEBD2CC080570067B5E4 /* LinkInMemoryCookieStore.swift */, + 3147CEBE2CC080570067B5E4 /* LinkSecureCookieStore.swift */, + ); + path = CookieStore; + sourceTree = ""; + }; + 3147CEC92CC1BF550067B5E4 /* PaymentMethodPicker */ = { + isa = PBXGroup; + children = ( + 3147CEC32CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift */, + 3147CEC42CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift */, + 3147CEC52CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift */, + 3147CEC62CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift */, + 3147CEC72CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift */, + 3147CEC82CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift */, + ); + path = PaymentMethodPicker; + sourceTree = ""; + }; 319DFD940EAA6EA0A0F4E771 /* Services */ = { isa = PBXGroup; children = ( @@ -794,6 +919,50 @@ path = Services; sourceTree = ""; }; + 31CC9B872CB5F74A00E84A38 /* Badge */ = { + isa = PBXGroup; + children = ( + 31CC9B862CB5F74A00E84A38 /* LinkBadgeView.swift */, + ); + path = Badge; + sourceTree = ""; + }; + 31CC9B892CB5F74A00E84A38 /* NavigationBar */ = { + isa = PBXGroup; + children = ( + 31CC9B882CB5F74A00E84A38 /* LinkNavigationBar.swift */, + ); + path = NavigationBar; + sourceTree = ""; + }; + 31CC9B8B2CB5F74A00E84A38 /* Notice */ = { + isa = PBXGroup; + children = ( + 31CC9B8A2CB5F74A00E84A38 /* LinkNoticeView.swift */, + ); + path = Notice; + sourceTree = ""; + }; + 31CC9B942CB5F74A00E84A38 /* Toast */ = { + isa = PBXGroup; + children = ( + 31CC9B932CB5F74A00E84A38 /* LinkToast.swift */, + ); + path = Toast; + sourceTree = ""; + }; + 31CC9B952CB5F74A00E84A38 /* Components */ = { + isa = PBXGroup; + children = ( + 31CC9B872CB5F74A00E84A38 /* Badge */, + 31CC9B892CB5F74A00E84A38 /* NavigationBar */, + 31CC9B8B2CB5F74A00E84A38 /* Notice */, + 3147CEC92CC1BF550067B5E4 /* PaymentMethodPicker */, + 31CC9B942CB5F74A00E84A38 /* Toast */, + ); + path = Components; + sourceTree = ""; + }; 35C0AFFBC393AF76586DAE4A /* Localizations */ = { isa = PBXGroup; children = ( @@ -961,6 +1130,7 @@ D0CE026F53D10DA10D7BC4E7 /* ConsumerSession.swift */, 4208AD2E0A737F5E0F00DE48 /* ConsumerSession+LookupResponse.swift */, E2D61B52BFA201D25E8F6428 /* ConsumerSession+PublishableKey.swift */, + 3147CEBF2CC080570067B5E4 /* CookieStore */, 981F958E99945A0318D47BBF /* PaymentDetails.swift */, F1E614E8481658A027599A92 /* STPAPIClient+Link.swift */, B662953D2C63F6C2007B6B14 /* PaymentDetailsShareResponse.swift */, @@ -1071,6 +1241,8 @@ 843180340B5C7406A1117541 /* Views */ = { isa = PBXGroup; children = ( + 31CC9BA22CB5F9D400E84A38 /* LinkInstantDebitMandateView.swift */, + 31CC9BA32CB5F9D400E84A38 /* LinkKeyboardAvoidingScrollView.swift */, 13FB3274557B85BA4C9FA6C0 /* LinkLegalTermsView.swift */, E5240ECFD40B8605939C4E09 /* LinkMoreInfoView.swift */, ); @@ -1080,8 +1252,11 @@ 86FB97D65ED6CB44B3E8B66C /* Extensions */ = { isa = PBXGroup; children = ( + 31CC9BA02CB5F93100E84A38 /* Button+Link.swift */, + 31CC9BA62CB610A300E84A38 /* ConfirmButton+Link.swift */, EFDE97D76542848E7821BA43 /* FormElement+Link.swift */, 04C8047FD8994D3FAA3D1A7A /* Intent+Link.swift */, + 3147CF002CC1D4350067B5E4 /* PaymentSheet-Configuration+Link.swift */, FD0EACE5F259BDE586A4A20C /* STPAnalyticsClient+Link.swift */, AE95456617FEEDDAC0CE5231 /* UIColor+Link.swift */, ); @@ -1124,7 +1299,14 @@ isa = PBXGroup; children = ( B02DD1BA93CC92A187051B2F /* LinkAccountContext.swift */, + 3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */, A1928BE9DFF116368B1A19DC /* LinkCookieKey.swift */, + 3147CED22CC1BF860067B5E4 /* LinkVerificationController.swift */, + 3147CED32CC1BF860067B5E4 /* LinkVerificationView.swift */, + 3147CED42CC1BF860067B5E4 /* LinkVerificationView-Header.swift */, + 3147CED52CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift */, + 3147CED62CC1BF860067B5E4 /* LinkVerificationViewController.swift */, + 3147CED72CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift */, ); path = Verification; sourceTree = ""; @@ -1283,6 +1465,7 @@ isa = PBXGroup; children = ( F8599012936F1A32FC191F06 /* ACH */, + 31CC9B952CB5F74A00E84A38 /* Components */, FD205A472E1B92651A0BB16F /* Controllers */, E46EF43F3DC5F4348B844EE7 /* Elements */, 86FB97D65ED6CB44B3E8B66C /* Extensions */, @@ -1347,6 +1530,8 @@ children = ( 52185F7315D3C4089D3465BD /* LinkPaymentController.swift */, B41560E0599A68A84F5C76D2 /* PaymentSheet-LinkConfirmOption.swift */, + 3147CEF82CC1D3910067B5E4 /* LinkPaymentController-New.swift */, + 3147CEF92CC1D3910067B5E4 /* PayWithLinkController-New.swift */, 5DCFBC65AF58423E0E8DD04A /* PayWithLinkController.swift */, ); path = Link; @@ -1369,6 +1554,7 @@ children = ( C71284B45BF3D3B487C1B99D /* InlineSignup */, C684CBDA487CC3E78676F52E /* LinkEmailElement.swift */, + 3147CED02CC1BF6E0067B5E4 /* LinkCardEditElement.swift */, ); path = Elements; sourceTree = ""; @@ -1498,8 +1684,21 @@ FCA28FF8CD5BA829A44CDCE7 /* Link */ = { isa = PBXGroup; children = ( + 31CC9B6C2CB5F69600E84A38 /* ButtonLinkSnapshotTests.swift */, + 31CC9B6D2CB5F69600E84A38 /* LinkBadgeViewSnapshotTest.swift */, + 31CC9B702CB5F69600E84A38 /* LinkInstantDebitMandateViewSnapshotTests.swift */, + 31CC9B712CB5F69600E84A38 /* LinkNavigationBarSnapshotTests.swift */, + 31CC9B722CB5F69600E84A38 /* LinkNoticeViewSnapshotTests.swift */, + 31CC9B742CB5F69600E84A38 /* LinkPopupURLParserTests.swift */, + 31CC9B752CB5F69600E84A38 /* LinkToastSnapshotTests.swift */, + 31CC9B762CB5F69600E84A38 /* LinkURLGeneratorTests.swift */, 22E4212F4A865B5AB5D72F99 /* LinkPopupURLParserTests.swift */, 9872CF28C8CA1D2C5499B8C5 /* LinkURLGeneratorTests.swift */, + 3147CEDE2CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift */, + 3147CEDF2CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift */, + 3147CEE02CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift */, + 3147CEFC2CC1D3A60067B5E4 /* LinkInlineSignupElementSnapshotTests.swift */, + 3147CEFD2CC1D3A60067B5E4 /* PayWithLinkViewController-WalletViewModelTests.swift */, ); path = Link; sourceTree = ""; @@ -1507,6 +1706,16 @@ FD205A472E1B92651A0BB16F /* Controllers */ = { isa = PBXGroup; children = ( + 3147CEE42CC1D3700067B5E4 /* PayWithLinkViewController.swift */, + 3147CEE52CC1D3700067B5E4 /* PayWithLinkViewController-BaseViewController.swift */, + 3147CEE62CC1D3700067B5E4 /* PayWithLinkViewController-LoaderViewController.swift */, + 3147CEE72CC1D3700067B5E4 /* PayWithLinkViewController-NewPaymentViewController.swift */, + 3147CEE82CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewController.swift */, + 3147CEE92CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewModel.swift */, + 3147CEEA2CC1D3700067B5E4 /* PayWithLinkViewController-UpdatePaymentViewController.swift */, + 3147CEEB2CC1D3700067B5E4 /* PayWithLinkViewController-VerifyAccountViewController.swift */, + 3147CEEC2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewController.swift */, + 3147CEED2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewModel.swift */, 617C44F9338DE2E93E318291 /* PayWithLinkWebController.swift */, ); path = Controllers; @@ -1707,6 +1916,17 @@ B63B2CF12BF8313D003810F3 /* VerticalPaymentMethodListViewControllerTest.swift in Sources */, 61CB0BD02BED985100E24A4C /* VerticalSavedPaymentMethodsViewControllerSnapshotTests.swift in Sources */, 8180BC3615767F896E2F9355 /* AddressViewControllerSnapshotTests.swift in Sources */, + 31CC9B792CB5F69600E84A38 /* LinkNavigationBarSnapshotTests.swift in Sources */, + 31CC9B7D2CB5F69600E84A38 /* ButtonLinkSnapshotTests.swift in Sources */, + 31CC9B7E2CB5F69600E84A38 /* LinkNoticeViewSnapshotTests.swift in Sources */, + 3147CEE12CC1BFA80067B5E4 /* LinkCardEditElementSnapshotTests.swift in Sources */, + 3147CEE22CC1BFA80067B5E4 /* LinkPaymentMethodPickerSnapshotTests.swift in Sources */, + 3147CEE32CC1BFA80067B5E4 /* LinkVerificationViewSnapshotTests.swift in Sources */, + 31CC9B7F2CB5F69600E84A38 /* LinkPopupURLParserTests.swift in Sources */, + 31CC9B802CB5F69600E84A38 /* LinkToastSnapshotTests.swift in Sources */, + 31CC9B812CB5F69600E84A38 /* LinkInstantDebitMandateViewSnapshotTests.swift in Sources */, + 31CC9B822CB5F69600E84A38 /* LinkURLGeneratorTests.swift in Sources */, + 31CC9B852CB5F69600E84A38 /* LinkBadgeViewSnapshotTest.swift in Sources */, B65FE7092BED33EA009A73FC /* VerticalPaymentMethodListViewControllerSnapshotTest.swift in Sources */, 37F750E1C99D6257E845A66E /* BacsDDMandateViewSnapshotTests.swift in Sources */, 694A3B36AC19FC1F87EF0CB1 /* CustomerSheetPaymentMethodAvailabilityTests.swift in Sources */, @@ -1751,6 +1971,8 @@ 61D842912CB06047009D2D51 /* FormMandateProviderTests.swift in Sources */, 61FB6BCD2C8901B200F8E074 /* EmbeddedPaymentMethodsViewSnapshotTests.swift in Sources */, 6B76DBD12C530B6C0037CD63 /* PaymentSheetGDPRConfirmFlowTests.swift in Sources */, + 3147CEFE2CC1D3A60067B5E4 /* PayWithLinkViewController-WalletViewModelTests.swift in Sources */, + 3147CEFF2CC1D3A60067B5E4 /* LinkInlineSignupElementSnapshotTests.swift in Sources */, EEC6283DB21D04AD5B77F9D2 /* STPApplePayContext+PaymentSheetTest.swift in Sources */, 714FBCA75296C291FDB3B345 /* STPCardBrandChoiceTest.swift in Sources */, E0E47773D3C0B432E26AA457 /* STPElementsSessionTest.swift in Sources */, @@ -1785,6 +2007,10 @@ 06976DDC67A61176FC54AA76 /* Data+SHA256.swift in Sources */, 6180A5CB2C8249D2009D1536 /* UIStackView+Separator.swift in Sources */, B63B2CF52BFBEEAD003810F3 /* PaymentMethodFormViewController.swift in Sources */, + 31CC9B972CB5F74A00E84A38 /* LinkNavigationBar.swift in Sources */, + 31CC9B982CB5F74A00E84A38 /* LinkNoticeView.swift in Sources */, + 31CC9B9A2CB5F74A00E84A38 /* LinkToast.swift in Sources */, + 31CC9B9C2CB5F74A00E84A38 /* LinkBadgeView.swift in Sources */, 108846A3D8EFD1D4DCC0DDBC /* NSAttributedString+Stripe.swift in Sources */, B55EFA2557B5BE39CC12E357 /* STPPaymentMethod+PaymentSheet.swift in Sources */, 3CB64564D5B6F092A2A3A5BE /* STPPaymentMethodParams+PaymentSheet.swift in Sources */, @@ -1813,6 +2039,7 @@ A4FF52567582E9774AE13348 /* PaymentDetails.swift in Sources */, 3E2279C28944A87EC6472101 /* STPAPIClient+Link.swift in Sources */, B8A7575878C5124CF5482097 /* VerificationSession.swift in Sources */, + 31CC9BA72CB610A300E84A38 /* ConfirmButton+Link.swift in Sources */, 9326393E775D29F8C661624B /* STPAPIClient+PaymentSheet.swift in Sources */, AA3A96D74B1659CB5725E95F /* CardExpiryDate.swift in Sources */, 64DE5688E4FBE92E1F49810C /* ExternalPaymentMethod.swift in Sources */, @@ -1824,6 +2051,9 @@ F3A34AD1CC2CBB899738C9D7 /* LinkInlineSignupElement.swift in Sources */, 56BB7C81AB3A24D3AD88A904 /* LinkInlineSignupView-CheckboxElement.swift in Sources */, 7479F814D1BC58A6B19F054C /* LinkInlineSignupView.swift in Sources */, + 3147CEC02CC080570067B5E4 /* LinkInMemoryCookieStore.swift in Sources */, + 3147CEC12CC080570067B5E4 /* LinkCookieStore.swift in Sources */, + 3147CEC22CC080570067B5E4 /* LinkSecureCookieStore.swift in Sources */, 258A75AF2E5393186C8850CA /* LinkEmailElement.swift in Sources */, EDE71E0BEDD94FB1101F3C10 /* FormElement+Link.swift in Sources */, C346B534D57A952D4415ADFD /* Intent+Link.swift in Sources */, @@ -1869,6 +2099,8 @@ 9E77F1E9F801AE970F1A5BE1 /* CustomerSheetConfiguration.swift in Sources */, AB8E1556F008083257A99E91 /* CustomerSheetError.swift in Sources */, B67D01B62C46FE9900ED8172 /* CVCReconfirmationVerticalViewController.swift in Sources */, + 31CC9BA42CB5F9D400E84A38 /* LinkKeyboardAvoidingScrollView.swift in Sources */, + 31CC9BA52CB5F9D400E84A38 /* LinkInstantDebitMandateView.swift in Sources */, 47B19F96CCEA290541E3B988 /* CardSectionElement.swift in Sources */, 04FEA90F2D0CB9D1C2029D21 /* CardSectionWithScannerView.swift in Sources */, 6BA8D3342B0C1F79008C51FF /* CVCRecollectionElement.swift in Sources */, @@ -1881,12 +2113,28 @@ DFA10770E494AFB895BA4EE2 /* TextFieldElement+Card.swift in Sources */, B6B3481CBA798CF22EE8411A /* TextFieldElement+IBAN.swift in Sources */, F90B7028426261188B66C834 /* Error+PaymentSheet.swift in Sources */, + 3147CECA2CC1BF550067B5E4 /* LinkPaymentMethodPicker.swift in Sources */, + 3147CECB2CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift in Sources */, + 3147CECC2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift in Sources */, + 3147CECD2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift in Sources */, + 3147CECE2CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift in Sources */, + 3147CECF2CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift in Sources */, FB653AA92B68F73344835A50 /* Intent.swift in Sources */, 253EED99635621AC0E788EBC /* IntentConfirmParams.swift in Sources */, 313F5F832B0BE5FD00BD98A9 /* Docs.docc in Sources */, BB73C2D2DB79BFC0A3186711 /* LinkPaymentController.swift in Sources */, 88BA38BE8949815F4DB79509 /* PayWithLinkController.swift in Sources */, 6B28A6B62BE9494500B47DBF /* CustomerSheetDataSource.swift in Sources */, + 3147CEEE2CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewModel.swift in Sources */, + 3147CEEF2CC1D3700067B5E4 /* PayWithLinkViewController-NewPaymentViewController.swift in Sources */, + 3147CEF02CC1D3700067B5E4 /* PayWithLinkViewController-WalletViewController.swift in Sources */, + 3147CEF12CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewModel.swift in Sources */, + 3147CEF22CC1D3700067B5E4 /* PayWithLinkViewController.swift in Sources */, + 3147CEF32CC1D3700067B5E4 /* PayWithLinkViewController-LoaderViewController.swift in Sources */, + 3147CEF42CC1D3700067B5E4 /* PayWithLinkViewController-BaseViewController.swift in Sources */, + 3147CEF52CC1D3700067B5E4 /* PayWithLinkViewController-SignUpViewController.swift in Sources */, + 3147CEF62CC1D3700067B5E4 /* PayWithLinkViewController-UpdatePaymentViewController.swift in Sources */, + 3147CEF72CC1D3700067B5E4 /* PayWithLinkViewController-VerifyAccountViewController.swift in Sources */, 8C91277A8FEFD0B914CC6564 /* PaymentSheet-LinkConfirmOption.swift in Sources */, 573E3DB554058AC1E34E34B6 /* AddPaymentMethodViewController.swift in Sources */, 8D4951AE0D793D01528F352D /* PaymentMethodTypeCollectionView.swift in Sources */, @@ -1900,6 +2148,7 @@ 3A52CFA2F9D0E1C677F4EEA4 /* PaymentSheet.swift in Sources */, F003E2D0185F1FC4FEC7D126 /* PaymentSheetAppearance.swift in Sources */, E672F7F306C9D2BC941AE8C9 /* PaymentSheetConfiguration.swift in Sources */, + 3147CF012CC1D4350067B5E4 /* PaymentSheet-Configuration+Link.swift in Sources */, 5867A512E488F5325CF38DD2 /* PaymentSheetDeferredValidator.swift in Sources */, 727874C468C0E1CD3653C91A /* PaymentSheetError.swift in Sources */, 45F8109B55B9013945ACB2C6 /* PaymentSheetFlowController.swift in Sources */, @@ -1919,9 +2168,17 @@ 6B7E675071649AE3047D388C /* PaymentSheetFormFactoryConfig.swift in Sources */, A8FC75044392659E39677C01 /* PaymentSheetIntentConfiguration.swift in Sources */, 5E00512CDFBC1C93781E20AB /* PaymentSheetLoader.swift in Sources */, + 3147CEFA2CC1D3910067B5E4 /* LinkPaymentController-New.swift in Sources */, + 3147CEFB2CC1D3910067B5E4 /* PayWithLinkController-New.swift in Sources */, 6A5997192BC88E28002A44CB /* InstantDebitsPaymentMethodElement.swift in Sources */, E5571A970EB9DFC4B690636F /* STPAnalyticsClient+PaymentSheet.swift in Sources */, 0B142FE21B861925B513143D /* STPApplePayContext+PaymentSheet.swift in Sources */, + 3147CED82CC1BF860067B5E4 /* LinkVerificationViewController.swift in Sources */, + 3147CED92CC1BF860067B5E4 /* LinkVerificationController.swift in Sources */, + 3147CEDA2CC1BF860067B5E4 /* LinkVerificationViewController-PresentationController.swift in Sources */, + 3147CEDB2CC1BF860067B5E4 /* LinkVerificationView.swift in Sources */, + 3147CEDC2CC1BF860067B5E4 /* LinkVerificationView-Header.swift in Sources */, + 3147CEDD2CC1BF860067B5E4 /* LinkVerificationView-LogoutView.swift in Sources */, 6103F2BC2BE45990002D67F8 /* SavedPaymentMethodManager.swift in Sources */, ED75C8F47475E4BE5D496C93 /* STPPaymentIntentShippingDetailsParams+PaymentSheet.swift in Sources */, 4313D6635F10EC460D2ED21E /* SavedPaymentMethodCollectionView.swift in Sources */, @@ -1929,6 +2186,8 @@ F4EA474D60D0889E7D48E1CF /* BankAccountInfoView.swift in Sources */, 057A899F4123F3716F2AC0FA /* USBankAccountPaymentMethodElement.swift in Sources */, 985DAA770BC0289D24A5999C /* AddressSearchResult.swift in Sources */, + 3147CED12CC1BF6E0067B5E4 /* LinkCardEditElement.swift in Sources */, + 31CC9BA12CB5F93100E84A38 /* Button+Link.swift in Sources */, 9DEDA3E0FFF73F9275F5F8F0 /* AutoCompleteViewController.swift in Sources */, A4CD99B2032CBFA7F957B1B8 /* String+AutoComplete.swift in Sources */, 190A1A5A871A82E5B6C09F41 /* BottomSheet3DS2ViewController.swift in Sources */, @@ -1950,6 +2209,7 @@ B8A217F26AAEC592B9B0D2E1 /* CardScanButton.swift in Sources */, 436A212E364FD78C3745DDA3 /* CardScanningView.swift in Sources */, 19A6D9D9951E13377F305263 /* CircularButton.swift in Sources */, + 3147CEBB2CC07E960067B5E4 /* LinkUtils.swift in Sources */, 6BA8D33A2B0C1FBF008C51FF /* CVCPaymentMethodInformationView.swift in Sources */, B662953E2C63F6C2007B6B14 /* PaymentDetailsShareResponse.swift in Sources */, F79DBDF42E5C0ED6B6DDC246 /* ConfirmButton.swift in Sources */, diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings b/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings index ad051bfd806..c7acb32a53d 100644 --- a/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings +++ b/StripePaymentSheet/StripePaymentSheet/Resources/Localizations/en.lproj/Localizable.strings @@ -10,6 +10,9 @@ /* Text for a button that, when tapped, displays another screen where the customer can add a new payment method */ "Add a payment method" = "Add a payment method"; +/* Button prompt to add a bank account as a payment method. */ +"Add bank account" = "Add bank account"; + /* Title shown above a view allowing the customer to save their first card. */ "Add card" = "Add card"; @@ -28,6 +31,12 @@ /* Text on a screen asking the user to approve a payment */ "Approve payment" = "Approve payment"; +/* Title of confirmation prompt when removing a saved card. */ +"Are you sure you want to remove this card?" = "Are you sure you want to remove this card?"; + +/* Title of confirmation prompt when removing a linked bank account. */ +"Are you sure you want to remove this linked account?" = "Are you sure you want to remove this linked account?"; + /* Text for back button */ "Back" = "Back"; @@ -70,6 +79,9 @@ /* Text providing link to terms for ACH payments */ "By continuing, you agree to authorize payments pursuant to these terms." = "By continuing, you agree to authorize payments pursuant to these terms."; +/* Mandate text displayed when paying via Link instant debit. */ +"By continuing, you agree to authorize payments pursuant to these terms." = "By continuing, you agree to authorize payments pursuant to these terms."; + /* Cash App mandate text */ "By continuing, you authorize %@ to debit your Cash App account for this payment and future payments in accordance with %@'s terms, until this authorization is revoked. You can change this anytime in your Cash App Settings." = "By continuing, you authorize %1$@ to debit your Cash App account for this payment and future payments in accordance with %2$@'s terms, until this authorization is revoked. You can change this anytime in your Cash App Settings."; @@ -97,12 +109,18 @@ /* Button text on a screen asking the user to approve a payment */ "Cancel and pay another way" = "Cancel and pay another way"; +/* Title for a button that allows the user to use a different email in the signup flow. */ +"Change email" = "Change email"; + /* TODO */ "Choose a payment method" = "Choose a payment method"; /* Accessibility label for the button to close the card scanner. */ "Close card scanner" = "Close card scanner"; +/* Text of a notification shown to the user when a login code is successfully sent via SMS. */ +"Code sent" = "Code sent"; + /* Title used for various UIs, including a button that confirms entered payment details or the selection of a payment method. */ "Confirm" = "Confirm"; @@ -121,12 +139,25 @@ /* Label for CPF/CPNJ (Brazil tax ID) field */ "CPF/CPNJ" = "CPF/CPNJ"; +/* Label for identifying the default payment method. */ +"Default" = "Default"; + /* Text for a button that allows manual entry of an address */ "Enter address manually" = "Enter address manually"; +/* Instructs the user to enter the code sent to their phone number in order to login to Link */ +"Enter the code sent to %@ to use Link to pay by default." = "Enter the code sent to %@ to use Link to pay by default."; + +/* Two factor authentication screen heading */ +"Enter your verification code" = "Enter your verification code"; + /* Label title for EPS Bank */ "EPS Bank" = "EPS Bank"; +/* A text notice shown when the user selects a card that requires +re-entering the security code (CVV/CVC). */ +"For security, please re-enter your card’s security code." = "For security, please re-enter your card’s security code."; + /* Select a bank dropdown for FPX */ "FPX Bank" = "FPX Bank"; @@ -139,6 +170,12 @@ /* iDEAL bank section title for iDEAL form entry. */ "iDEAL Bank" = "iDEAL Bank"; +/* Title for a button that when tapped creates a Link account for the user. */ +"Join Link" = "Join Link"; + +/* Title of the logout action. */ +"Log out of Link" = "Log out of Link"; + /* Title shown above a view containing the customer's payment method that they can delete or update */ "Manage payment method" = "Manage payment method"; @@ -157,6 +194,9 @@ /* Title shown above a section containing payment methods that a customer can choose to pay with e.g. card, bank account, etc. */ "New payment method" = "New payment method"; +/* Text of a label for confirming an email address. E.g., 'Not user@example.com?' */ +"Not %@?" = "Not %@?"; + /* Countdown timer text on a screen asking the user to approve a payment */ "Open your UPI app to approve your payment within %@" = "Open your UPI app to approve your payment within %@"; @@ -198,9 +238,15 @@ e.g, 'Pay faster at Example, Inc. and thousands of businesses.' */ /* US Bank Account copy title for Mobile payment element form */ "Pay with your bank account in just a few steps." = "Pay with your bank account in just a few steps."; +/* Label for a section displaying payment details. */ +"Payment" = "Payment"; + /* Text on a screen that indicates a payment has failed */ "Payment failed" = "Payment failed"; +/* Title for a section listing one or more payment methods. */ +"Payment methods" = "Payment methods"; + /* Text on a screen that indicates a payment has failed informing the user we are asking the user to try a different payment method */ "Please go back and select another payment method" = "Please go back and select another payment method"; @@ -216,9 +262,15 @@ e.g, 'Pay faster at Example, Inc. and thousands of businesses.' */ /* Title for confirmation alert to remove a card */ "Remove card?" = "Remove card?"; +/* Title for a button that when tapped removes a linked bank account. */ +"Remove linked account" = "Remove linked account"; + /* Title shown above a view containing a customer's payment method that they can delete */ "Remove payment method" = "Remove payment method"; +/* Label for a button that re-sends the a login code when tapped */ +"Resend code" = "Resend code"; + /* A button used for saving a new payment method */ "Save" = "Save"; @@ -247,6 +299,9 @@ to be saved and used in future checkout sessions. */ /* Title shown above a button that represents the customer's saved payment method e.g., a saved credit card or bank account. */ "Saved" = "Saved"; +/* Title for the Link signup screen */ +"Secure 1⁠-⁠click checkout" = "Secure 1⁠-⁠click checkout"; + /* Title shown above a view containing the customer's card payment methods */ "Select card" = "Select card"; @@ -256,6 +311,12 @@ to be saved and used in future checkout sessions. */ /* Title shown above a carousel containing the customer's payment methods */ "Select your payment method" = "Select your payment method"; +/* Label for a button or menu item that sets a payment method as default when tapped. */ +"Set as default" = "Set as default"; + +/* Label of a checkbox that when checked makes a payment method as the default one. */ +"Set as default payment method" = "Set as default payment method"; + /* Label of a button displayed below a payment method form. Tapping the button sets the payment method up for future use */ "Set up" = "Set up"; @@ -266,6 +327,9 @@ is not supported by the merchant */ /* Accessibility label for an action or a button that shows a menu. */ "Show menu" = "Show menu"; +/* Two factor authentication screen heading */ +"Sign in to your Link account" = "Sign in to your Link account"; + /* Subtitle shown on a button allowing a user to select to pay with Link. */ "Simple, secure one-click payments" = "Simple, secure one-click payments"; @@ -278,18 +342,49 @@ is not supported by the merchant */ /* Prompt for microdeposit verification before completing purchase with merchant. %@ will be replaced by merchant business name */ "Stripe will deposit $0.01 to your account in 1-2 business days. Then you’ll get an email with instructions to complete payment to %@." = "Stripe will deposit $0.01 to your account in 1-2 business days. Then you’ll get an email with instructions to complete payment to %@."; +/* Accessibility hint to tell the user that they can tap to hide additional content. */ +"Tap to close" = "Tap to close"; + +/* Accessibility hint to tell the user that they can tap to reveal additional content. */ +"Tap to expand" = "Tap to expand"; + /* An error message. */ "The IBAN you entered is incomplete." = "The IBAN you entered is incomplete."; /* An error message. */ "The IBAN you entered is invalid, \"%@\" is not a supported country code." = "The IBAN you entered is invalid, \"%@\" is not a supported country code."; +/* Error message shown when the user enters an expired verification code. */ +"The provided verification code has expired." = "The provided verification code has expired."; + +/* Error message shown when the user enters an incorrect verification code. */ +"The provided verification code is incorrect." = "The provided verification code is incorrect."; + +/* A text notice shown when the user selects an expired card. */ +"This card has expired. Update your card info or choose a different payment method." = "This card has expired. Update your card info or choose a different payment method."; + +/* Text of a label indicating that a payment method is the default. */ +"This is your default" = "This is your default"; + +/* Error message shown when the user enters an incorrect verification code too many times. */ +"Too many attempts. Please try again in a few minutes." = "Too many attempts. Please try again in a few minutes."; + +/* Label shown when a payment method cannot be used for the current transaction. */ +"Unavailable for this purchase" = "Unavailable for this purchase"; + /* Title for a button that when tapped, updates a card brand. */ "Update" = "Update"; +/* Title for a button that when tapped, presents a screen for updating a card. Also +the heading the screen itself. */ +"Update card" = "Update card"; + /* Title for a screen for updating a card brand. */ "Update card brand" = "Update card brand"; +/* Two factor authentication screen heading */ +"Use your saved info to check out faster" = "Use your saved info to check out faster"; + /* Text shown on a button that displays a customer's default saved payment method. When tapped, it opens a screen that shows all of the customer's saved payment methods. */ "View more" = "View more"; diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/Contents.json b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/Contents.json new file mode 100644 index 00000000000..f831e5441f5 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "back_button.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "back_button@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "back_button@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button.png new file mode 100644 index 00000000000..bfc423f9658 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button@2x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button@2x.png new file mode 100644 index 00000000000..8b83c43bce3 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button@2x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button@3x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button@3x.png new file mode 100644 index 00000000000..8b83c43bce3 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/back_button.imageset/back_button@3x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/Contents.json b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/Contents.json new file mode 100644 index 00000000000..0e0263b9db1 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon_add_bordered.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_add_bordered@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon_add_bordered@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered.png new file mode 100644 index 00000000000..15d2508054d Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered@2x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered@2x.png new file mode 100644 index 00000000000..87b303cd88f Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered@2x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered@3x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered@3x.png new file mode 100644 index 00000000000..4571fd931ce Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_add_bordered.imageset/icon_add_bordered@3x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/Contents.json b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/Contents.json new file mode 100644 index 00000000000..2f1b18ab5fa --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon_cancel.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_cancel@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon_cancel@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel.png new file mode 100644 index 00000000000..e27b64694d0 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel@2x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel@2x.png new file mode 100644 index 00000000000..eeb51c456b4 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel@2x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel@3x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel@3x.png new file mode 100644 index 00000000000..eeb51c456b4 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_cancel.imageset/icon_cancel@3x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_error.imageset/Contents.json b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_error.imageset/Contents.json new file mode 100644 index 00000000000..42245a1ec1d --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_error.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon_link_error@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_error.imageset/icon_link_error@3x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_error.imageset/icon_link_error@3x.png new file mode 100644 index 00000000000..84a7a861ec6 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_error.imageset/icon_link_error@3x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_success.imageset/Contents.json b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_success.imageset/Contents.json new file mode 100644 index 00000000000..1573b0fbe20 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_success.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon_link_success@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_success.imageset/icon_link_success@3x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_success.imageset/icon_link_success@3x.png new file mode 100644 index 00000000000..db8ff7fc4b5 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_link_success.imageset/icon_link_success@3x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu.imageset/Contents.json b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu.imageset/Contents.json new file mode 100644 index 00000000000..c34bcd9af97 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon_menu@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu.imageset/icon_menu@3x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu.imageset/icon_menu@3x.png new file mode 100644 index 00000000000..202876bfaad Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu.imageset/icon_menu@3x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/Contents.json b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/Contents.json new file mode 100644 index 00000000000..7edcc25ee4e --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon_menu_horizontal.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_menu_horizontal@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon_menu_horizontal@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal.png new file mode 100644 index 00000000000..2fc3f99532e Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal@2x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal@2x.png new file mode 100644 index 00000000000..8f172afd222 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal@2x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal@3x.png b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal@3x.png new file mode 100644 index 00000000000..431051c7863 Binary files /dev/null and b/StripePaymentSheet/StripePaymentSheet/Resources/StripePaymentSheet.xcassets/Link/icon_menu_horizontal.imageset/icon_menu_horizontal@3x.png differ diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Categories/String+Localized.swift b/StripePaymentSheet/StripePaymentSheet/Source/Categories/String+Localized.swift index 1b992c75128..3aa38c5aee7 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Categories/String+Localized.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Categories/String+Localized.swift @@ -73,6 +73,16 @@ extension String.Localized { STPLocalizedString("Back", "Text for back button") } + static var update_card: String { + STPLocalizedString( + "Update card", + """ + Title for a button that when tapped, presents a screen for updating a card. Also + the heading the screen itself. + """ + ) + } + static var update_card_brand: String { STPLocalizedString( "Update card brand", diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/Images.swift b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/Images.swift index 064e76c144d..6febbdc2516 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/Images.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/Images.swift @@ -49,6 +49,8 @@ enum Image: String, CaseIterable, ImageMaker { case icon_chevron_left = "icon_chevron_left" case icon_chevron_right = "icon_chevron_right" case icon_lock = "icon_lock" + case icon_menu = "icon_menu" + case icon_menu_horizontal = "icon_menu_horizontal" case icon_plus = "icon_plus" case icon_x = "icon_x" case icon_x_standalone = "icon_x_standalone" @@ -56,6 +58,11 @@ enum Image: String, CaseIterable, ImageMaker { case icon_edit = "icon_edit" // Link + case back_button = "back_button" + case icon_cancel = "icon_cancel" + case icon_add_bordered = "icon_add_bordered" + case icon_link_success = "icon_link_success" + case icon_link_error = "icon_link_error" case link_logo = "link_logo" case link_logo_bw = "link_logo_bw" case link_logo_knockout = "link_logo_knockout" diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift index 8fb136865da..976fb8091b5 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift @@ -13,7 +13,9 @@ import UIKit protocol PaymentSheetLinkAccountInfoProtocol { var email: String { get } + var redactedPhoneNumber: String? { get } var isRegistered: Bool { get } + var isLoggedIn: Bool { get } } struct LinkPMDisplayDetails { @@ -49,12 +51,17 @@ class PaymentSheetLinkAccount: PaymentSheetLinkAccountInfoProtocol { // Dependencies let apiClient: STPAPIClient + let cookieStore: LinkCookieStore /// Publishable key of the Consumer Account. private(set) var publishableKey: String? let email: String + var redactedPhoneNumber: String? { + return currentSession?.redactedPhoneNumber + } + var isRegistered: Bool { return currentSession != nil } @@ -83,12 +90,14 @@ class PaymentSheetLinkAccount: PaymentSheetLinkAccountInfoProtocol { email: String, session: ConsumerSession?, publishableKey: String?, - apiClient: STPAPIClient = .shared + apiClient: STPAPIClient = .shared, + cookieStore: LinkCookieStore = LinkSecureCookieStore.shared ) { self.email = email self.currentSession = session self.publishableKey = publishableKey self.apiClient = apiClient + self.cookieStore = cookieStore } func signUp( @@ -144,6 +153,96 @@ class PaymentSheetLinkAccount: PaymentSheetLinkAccountInfoProtocol { } } + func startVerification(completion: @escaping (Result) -> Void) { + guard case .requiresVerification = sessionState else { + DispatchQueue.main.async { + completion(.success(false)) + } + return + } + + guard let session = currentSession else { + assertionFailure() + DispatchQueue.main.async { + completion( + .failure( + PaymentSheetError.unknown(debugDescription: "Don't call verify if not needed") + ) + ) + } + return + } + + session.startVerification( + with: apiClient, + cookieStore: cookieStore, + consumerAccountPublishableKey: publishableKey + ) { [weak self] result in + switch result { + case .success(let newSession): + self?.currentSession = newSession + completion(.success(newSession.hasStartedSMSVerification)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func verify(with oneTimePasscode: String, completion: @escaping (Result) -> Void) { + guard case .requiresVerification = sessionState, + hasStartedSMSVerification, + let session = currentSession + else { + assertionFailure() + DispatchQueue.main.async { + completion( + .failure( + PaymentSheetError.unknown(debugDescription: "Don't call verify if not needed") + ) + ) + } + return + } + + session.confirmSMSVerification( + with: oneTimePasscode, + with: apiClient, + cookieStore: cookieStore, + consumerAccountPublishableKey: publishableKey + ) { [weak self] result in + switch result { + case .success(let verifiedSession): + self?.currentSession = verifiedSession + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func createLinkAccountSession( + completion: @escaping (Result) -> Void + ) { + guard let session = currentSession else { + assertionFailure() + completion( + .failure( + PaymentSheetError.unknown( + debugDescription: "Linking account session without valid consumer session" + ) + ) + ) + return + } + + retryingOnAuthError(completion: completion) { [publishableKey] completionWrapper in + session.createLinkAccountSession( + consumerAccountPublishableKey: publishableKey, + completion: completionWrapper + ) + } + } + func createPaymentDetails( with paymentMethodParams: STPPaymentMethodParams, completion: @escaping (Result) -> Void @@ -166,6 +265,91 @@ class PaymentSheetLinkAccount: PaymentSheetLinkAccountInfoProtocol { } } + func createPaymentDetails( + linkedAccountId: String, + completion: @escaping (Result) -> Void + ) { + guard let session = currentSession else { + assertionFailure() + completion(.failure(PaymentSheetError.unknown(debugDescription: "Saving to Link without valid session"))) + return + } + retryingOnAuthError(completion: completion) { [publishableKey] completionWrapper in + session.createPaymentDetails( + linkedAccountId: linkedAccountId, + consumerAccountPublishableKey: publishableKey, + completion: completionWrapper + ) + } + } + + func listPaymentDetails( + completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void + ) { + guard let session = currentSession else { + assertionFailure() + completion(.failure(PaymentSheetError.unknown(debugDescription: "Paying with Link without valid session"))) + return + } + + retryingOnAuthError(completion: completion) { [apiClient, publishableKey] completionWrapper in + session.listPaymentDetails( + with: apiClient, + consumerAccountPublishableKey: publishableKey, + completion: completionWrapper + ) + } + } + + func deletePaymentDetails(id: String, completion: @escaping (Result) -> Void) { + guard let session = currentSession else { + assertionFailure() + return completion( + .failure( + PaymentSheetError.unknown( + debugDescription: "Deleting Link payment details without valid session" + ) + ) + ) + } + + retryingOnAuthError(completion: completion) { [apiClient, publishableKey] completionWrapper in + session.deletePaymentDetails( + with: apiClient, + id: id, + consumerAccountPublishableKey: publishableKey, + completion: completionWrapper + ) + } + } + + func updatePaymentDetails( + id: String, + updateParams: UpdatePaymentDetailsParams, + completion: @escaping (Result) -> Void + ) { + guard let session = currentSession else { + assertionFailure() + return completion( + .failure( + PaymentSheetError.unknown( + debugDescription: "Updating Link payment details without valid session" + ) + ) + ) + } + + retryingOnAuthError(completion: completion) { [apiClient, publishableKey] completionWrapper in + session.updatePaymentDetails( + with: apiClient, + id: id, + updateParams: updateParams, + consumerAccountPublishableKey: publishableKey, + completion: completionWrapper + ) + } + } + func sharePaymentDetails(id: String, cvc: String?, completion: @escaping (Result) -> Void) { guard let session = currentSession else { assertionFailure() @@ -195,6 +379,14 @@ class PaymentSheetLinkAccount: PaymentSheetLinkAccountInfoProtocol { // We don't need to do anything if this fails, the key will expire automatically. } } + + func markEmailAsLoggedOut() { + guard let hashedEmail = email.lowercased().sha256 else { + return + } + + cookieStore.write(key: .lastLogoutEmail, value: hashedEmail) + } } // MARK: - Equatable @@ -307,3 +499,96 @@ extension PaymentSheetLinkAccount { return params } } + +// MARK: - Payment method availability + +extension PaymentSheetLinkAccount { + + /// Returns a set containing the Payment Details types that the user is able to use for confirming the given `intent`. + /// - Parameter intent: The Intent that the user is trying to confirm. + /// - Returns: A set containing the supported Payment Details types. + func supportedPaymentDetailsTypes(for elementsSession: STPElementsSession) -> Set { + guard let currentSession = currentSession, let fundingSources = elementsSession.linkFundingSources else { + return [] + } + + let fundingSourceDetailsTypes = Set(fundingSources.compactMap { $0.detailsType }) + + // Take the intersection of the consumer session types and the merchant-provided Link funding sources + var supportedPaymentDetailsTypes = fundingSourceDetailsTypes.intersection(currentSession.supportedPaymentDetailsTypes) + + // Special testmode handling + if apiClient.isTestmode && Self.emailSupportsMultipleFundingSourcesOnTestMode(email) { + supportedPaymentDetailsTypes.insert(.bankAccount) + } + + return supportedPaymentDetailsTypes + } + + func supportedPaymentMethodTypes(for elementsSession: STPElementsSession) -> [STPPaymentMethodType] { + var supportedPaymentMethodTypes = [STPPaymentMethodType]() + + for paymentDetailsType in supportedPaymentDetailsTypes(for: elementsSession) { + switch paymentDetailsType { + case .card: + supportedPaymentMethodTypes.append(.card) + case .bankAccount: + break +// TODO(link): Fix instant debits +// supportedPaymentMethodTypes.append(.instantDebits) + case .unparsable: + break + } + } + + if supportedPaymentMethodTypes.isEmpty { + // Card is the default payment method type when no other type is available. + supportedPaymentMethodTypes.append(.card) + } + + return supportedPaymentMethodTypes + } +} + +// MARK: - Helpers + +private extension PaymentSheetLinkAccount { + + /// On *testmode* we use special email addresses for testing multiple funding sources. This method returns `true` + /// if the given `email` is one of such email addresses. + /// + /// - Parameter email: Email. + /// - Returns: Whether or not should enable multiple funding sources on test mode. + static func emailSupportsMultipleFundingSourcesOnTestMode(_ email: String) -> Bool { + return email.contains("+multiple_funding_sources@") + } + +} + +private extension LinkSettings.FundingSource { + var detailsType: ConsumerPaymentDetails.DetailsType? { + switch self { + case .card: + return .card + case .bankAccount: + return .bankAccount + } + } +} + +// MARK: UpdatePaymentDetailsParams + +struct UpdatePaymentDetailsParams { + enum DetailsType { + case card(expiryDate: CardExpiryDate, billingDetails: STPPaymentMethodBillingDetails? = nil) + // updating bank not supported + } + + let isDefault: Bool? + let details: DetailsType? + + init(isDefault: Bool? = nil, details: DetailsType? = nil) { + self.isDefault = isDefault + self.details = details + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession+PublishableKey.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession+PublishableKey.swift index a8d8bf84426..227950a10e2 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession+PublishableKey.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession+PublishableKey.swift @@ -12,13 +12,16 @@ extension ConsumerSession { final class SessionWithPublishableKey: Decodable { let consumerSession: ConsumerSession let publishableKey: String + let authSessionClientSecret: String? init( consumerSession: ConsumerSession, - publishableKey: String + publishableKey: String, + authSessionClientSecret: String? = nil ) { self.consumerSession = consumerSession self.publishableKey = publishableKey + self.authSessionClientSecret = authSessionClientSecret } } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift index ee6fb7f5a3d..06ec7299803 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift @@ -15,29 +15,39 @@ import UIKit final class ConsumerSession: Decodable { let clientSecret: String let emailAddress: String + let redactedPhoneNumber: String let verificationSessions: [VerificationSession] + let supportedPaymentDetailsTypes: Set init( clientSecret: String, emailAddress: String, - verificationSessions: [VerificationSession] + redactedPhoneNumber: String, + verificationSessions: [VerificationSession], + supportedPaymentDetailsTypes: Set ) { self.clientSecret = clientSecret self.emailAddress = emailAddress + self.redactedPhoneNumber = redactedPhoneNumber self.verificationSessions = verificationSessions + self.supportedPaymentDetailsTypes = supportedPaymentDetailsTypes } private enum CodingKeys: String, CodingKey { case clientSecret case emailAddress + case redactedPhoneNumber case verificationSessions + case supportedPaymentDetailsTypes = "supportPaymentDetailsTypes" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.clientSecret = try container.decode(String.self, forKey: .clientSecret) self.emailAddress = try container.decode(String.self, forKey: .emailAddress) + self.redactedPhoneNumber = try container.decode(String.self, forKey: .redactedPhoneNumber) self.verificationSessions = try container.decodeIfPresent([ConsumerSession.VerificationSession].self, forKey: .verificationSessions) ?? [] + self.supportedPaymentDetailsTypes = try container.decodeIfPresent(Set.self, forKey: .supportedPaymentDetailsTypes) ?? [] } } @@ -69,9 +79,10 @@ extension ConsumerSession { class func lookupSession( for email: String?, with apiClient: STPAPIClient = STPAPIClient.shared, + cookieStore: LinkCookieStore = LinkSecureCookieStore.shared, completion: @escaping (Result) -> Void ) { - apiClient.lookupConsumerSession(for: email, completion: completion) + apiClient.lookupConsumerSession(for: email, cookieStore: cookieStore, completion: completion) } class func signUp( @@ -124,6 +135,100 @@ extension ConsumerSession { completion: completion) } + func createPaymentDetails( + linkedAccountId: String, + with apiClient: STPAPIClient = STPAPIClient.shared, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + apiClient.createPaymentDetails( + for: clientSecret, + linkedAccountId: linkedAccountId, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion) + } + + func startVerification( + type: VerificationSession.SessionType = .sms, + locale: Locale = .autoupdatingCurrent, + with apiClient: STPAPIClient = STPAPIClient.shared, + cookieStore: LinkCookieStore = LinkSecureCookieStore.shared, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + apiClient.startVerification( + for: clientSecret, + type: type, + locale: locale, + cookieStore: cookieStore, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion) + } + + func confirmSMSVerification( + with code: String, + with apiClient: STPAPIClient = STPAPIClient.shared, + cookieStore: LinkCookieStore = LinkSecureCookieStore.shared, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + apiClient.confirmSMSVerification( + for: clientSecret, + with: code, + cookieStore: cookieStore, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion) + } + + func createLinkAccountSession( + with apiClient: STPAPIClient = STPAPIClient.shared, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + apiClient.createLinkAccountSession( + for: clientSecret, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion) + } + + func listPaymentDetails( + with apiClient: STPAPIClient = STPAPIClient.shared, + consumerAccountPublishableKey: String?, + completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void + ) { + apiClient.listPaymentDetails( + for: clientSecret, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion) + } + + func deletePaymentDetails( + with apiClient: STPAPIClient = STPAPIClient.shared, + id: String, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + apiClient.deletePaymentDetails( + for: clientSecret, + id: id, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion) + } + + func updatePaymentDetails( + with apiClient: STPAPIClient = STPAPIClient.shared, + id: String, + updateParams: UpdatePaymentDetailsParams, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + apiClient.updatePaymentDetails( + for: clientSecret, id: id, + updateParams: updateParams, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion) + } + func sharePaymentDetails( with apiClient: STPAPIClient = STPAPIClient.shared, id: String, @@ -141,11 +246,13 @@ extension ConsumerSession { func logout( with apiClient: STPAPIClient = STPAPIClient.shared, + cookieStore: LinkCookieStore = LinkSecureCookieStore.shared, consumerAccountPublishableKey: String?, completion: @escaping (Result) -> Void ) { apiClient.logout( consumerSessionClientSecret: clientSecret, + cookieStore: cookieStore, consumerAccountPublishableKey: consumerAccountPublishableKey, completion: completion) } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkCookieStore.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkCookieStore.swift new file mode 100644 index 00000000000..c484191d33a --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkCookieStore.swift @@ -0,0 +1,43 @@ +// +// LinkCookieStore.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 12/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +/// A protocol that cookie storage objects should conform to. +/// +/// Provides an interface for basic CRUD functionality. +protocol LinkCookieStore { + /// Writes a cookie to the store. + /// - Parameters: + /// - key: Cookie identifier. + /// - value: Cookie value. + /// - allowSync: True if this cookie should be sync'd across devices + func write(key: LinkCookieKey, value: String, allowSync: Bool) + + /// Retrieves a cookie by key. + /// - Parameter key: Cookie identifier. + /// - Returns: The cookie value, or `nil` if it doesn't exist. + func read(key: LinkCookieKey) -> String? + + /// Deletes a stored cookie identified by key. + /// - Parameter key: Cookie identifier. + func delete(key: LinkCookieKey) +} + +extension LinkCookieStore { + func write(key: LinkCookieKey, value: String) { + self.write(key: key, value: value, allowSync: false) + } +} + +// MARK: - Helpers + +extension LinkCookieStore { + func clear() { + delete(key: .lastLogoutEmail) + delete(key: .lastSignupEmail) + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkInMemoryCookieStore.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkInMemoryCookieStore.swift new file mode 100644 index 00000000000..96a81d90834 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkInMemoryCookieStore.swift @@ -0,0 +1,24 @@ +// +// LinkInMemoryCookieStore.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 12/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +/// In-memory cookie store. +final class LinkInMemoryCookieStore: LinkCookieStore { + private var data: [LinkCookieKey: String] = [:] + + func write(key: LinkCookieKey, value: String, allowSync: Bool) { + data[key] = value + } + + func read(key: LinkCookieKey) -> String? { + return data[key] + } + + func delete(key: LinkCookieKey) { + data.removeValue(forKey: key) + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkSecureCookieStore.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkSecureCookieStore.swift new file mode 100644 index 00000000000..63df889ea6b --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/CookieStore/LinkSecureCookieStore.swift @@ -0,0 +1,103 @@ +// +// LinkSecureCookieStore.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 12/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import Security + +/// A secure cookie store backed by Keychain. +final class LinkSecureCookieStore: LinkCookieStore { + + static let shared: LinkSecureCookieStore = .init() + + private init() {} + + func write(key: LinkCookieKey, value: String, allowSync: Bool) { + guard let data = value.data(using: .utf8) else { + return + } + + let query = queryForKey(key, additionalParams: [ + kSecValueData as String: data, + kSecAttrSynchronizable as String: allowSync ? kCFBooleanTrue as Any : kCFBooleanFalse as Any, + ]) + + delete(key: key) + let status = SecItemAdd(query as CFDictionary, nil) + assert( + status == noErr || status == errSecDuplicateItem, + "Unexpected status code \(status)" + ) + + if status == errSecDuplicateItem { + let updateQuery = queryForKey(key) + let updatedValue: [String: Any] = [kSecValueData as String: data] + let status = SecItemUpdate(updateQuery as CFDictionary, updatedValue as CFDictionary) + assert(status == noErr, "Unexpected status code \(status)") + } + } + + func read(key: LinkCookieKey) -> String? { + let query = queryForKey(key, additionalParams: [ + kSecReturnData as String: kCFBooleanTrue as Any, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + ]) + + var result: AnyObject? + + let status = SecItemCopyMatching(query as CFDictionary, &result) + // Disable this check for UI tests + + assert( + status == noErr || status == errSecItemNotFound, + "Unexpected status code \(status)" + ) + + guard + status == noErr, + let data = result as? Data + else { + return nil + } + + return String(data: data, encoding: .utf8) + } + + func delete(key: LinkCookieKey) { + let query = queryForKey(key, additionalParams: [ + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ]) + + let status = SecItemDelete(query as CFDictionary) + assert( + status == noErr || status == errSecItemNotFound, + "Unexpected status code \(status)" + ) + } + + private func queryForKey( + _ key: LinkCookieKey, + additionalParams: [String: Any]? = nil + ) -> [String: Any] { + // This must be unique across apps OR the apps must share + // a keychain access group. To be safe, we'll partition it + // by bundle ID. + let accountId = "STP-\(Bundle.main.bundleIdentifier ?? "")-\(key.rawValue)" + var query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: accountId, + ] as [String: Any] + + additionalParams?.forEach({ (key, value) in + query[key] = value + }) + + return query + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift index 20897763728..052d7681118 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift @@ -20,17 +20,231 @@ typealias ConsumerSessionWithPaymentDetails = (session: ConsumerSession, payment */ final class ConsumerPaymentDetails: Decodable { let stripeID: String + let details: Details + var isDefault: Bool - init(stripeID: String) { + // TODO(csabol) : Billing address + + init(stripeID: String, + details: Details, + isDefault: Bool) { self.stripeID = stripeID + self.details = details + self.isDefault = isDefault } private enum CodingKeys: String, CodingKey { case stripeID = "id" + case isDefault } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.stripeID = try container.decode(String.self, forKey: .stripeID) + // The payment details are included in the dictionary, so we pass the whole dict to Details + self.details = try decoder.singleValueContainer().decode(Details.self) + self.isDefault = try container.decode(Bool.self, forKey: .isDefault) + } +} + +// MARK: - Details +/// :nodoc: +extension ConsumerPaymentDetails { + enum DetailsType: String, CaseIterable, SafeEnumCodable { + case card = "CARD" + case bankAccount = "BANK_ACCOUNT" + case unparsable = "" + } + + enum Details: SafeEnumDecodable { + case card(card: Card) + case bankAccount(bankAccount: BankAccount) + case unparsable + + private enum CodingKeys: String, CodingKey { + case type + case card = "cardDetails" + case bankAccount = "bankAccountDetails" + } + + // Our JSON structure doesn't align with Swift's expected structure for enums with associated values, so we do custom decoding. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(DetailsType.self, forKey: CodingKeys.type) + switch type { + case .card: + self = .card(card: try container.decode(Card.self, forKey: CodingKeys.card)) + case .bankAccount: + self = .bankAccount(bankAccount: try container.decode(BankAccount.self, forKey: CodingKeys.bankAccount)) + case .unparsable: + self = .unparsable + } + } + } + + var type: DetailsType { + switch details { + case .card: + return .card + case .bankAccount: + return .bankAccount + case .unparsable: + return .unparsable + } + } +} + +// MARK: - Card checks + +extension ConsumerPaymentDetails.Details { + /// For internal SDK use only + final class CardChecks: Codable { + enum CVCCheck: String, SafeEnumCodable { + case pass = "PASS" + case fail = "FAIL" + case unchecked = "UNCHECKED" + case unavailable = "UNAVAILABLE" + case stateInvalid = "STATE_INVALID" + // Catch all + case unparsable = "" + } + + let cvcCheck: CVCCheck + + init(cvcCheck: CVCCheck) { + self.cvcCheck = cvcCheck + } + } +} + +// MARK: - Details.Card +extension ConsumerPaymentDetails.Details { + final class Card: Codable { + let expiryYear: Int + let expiryMonth: Int + let brand: String + let last4: String + let checks: CardChecks? + + private enum CodingKeys: String, CodingKey { + case expiryYear = "expYear" + case expiryMonth = "expMonth" + case brand + case last4 + case checks + } + + /// A frontend convenience property, i.e. not part of the API Object + /// As such this is deliberately omitted from CodingKeys + var cvc: String? + + init(expiryYear: Int, + expiryMonth: Int, + brand: String, + last4: String, + checks: CardChecks?) { + self.expiryYear = expiryYear + self.expiryMonth = expiryMonth + self.brand = brand + self.last4 = last4 + self.checks = checks + } + } +} + +// MARK: - Details.Card - Helpers +extension ConsumerPaymentDetails.Details.Card { + + var shouldRecollectCardCVC: Bool { + switch checks?.cvcCheck { + case .fail, .unavailable, .unchecked: + return true + default: + return false + } + } + + var expiryDate: CardExpiryDate { + return CardExpiryDate(month: expiryMonth, year: expiryYear) + } + + var hasExpired: Bool { + return expiryDate.expired() + } + + var stpBrand: STPCardBrand { + return STPCard.brand(from: brand) } + +} + +// MARK: - Details.BankAccount +extension ConsumerPaymentDetails.Details { + final class BankAccount: Codable { + let iconCode: String? + let name: String + let last4: String + + private enum CodingKeys: String, CodingKey { + case iconCode = "bankIconCode" + case name = "bankName" + case last4 + } + + init(iconCode: String?, + name: String, + last4: String) { + self.iconCode = iconCode + self.name = name + self.last4 = last4 + } + } +} + +extension ConsumerPaymentDetails { + var paymentSheetLabel: String { + switch details { + case .card(let card): + return "••••\(card.last4)" + case .bankAccount(let bank): + return "••••\(bank.last4)" + case .unparsable: + return "" + } + } + + var cvc: String? { + switch details { + case .card(let card): + return card.cvc + case .bankAccount: + return nil + case .unparsable: + return nil + } + } + + var accessibilityDescription: String { + switch details { + case .card(let card): + // TODO(ramont): investigate why this returns optional + let cardBrandName = STPCardBrandUtilities.stringFrom(card.stpBrand) ?? "" + let digits = card.last4.map({ String($0) }).joined(separator: ", ") + return String( + format: String.Localized.card_brand_ending_in_last_4, + cardBrandName, + digits + ) + case .bankAccount(let bank): + let digits = bank.last4.map({ String($0) }).joined(separator: ", ") + return String( + format: String.Localized.bank_name_account_ending_in_last_4, + bank.name, + digits + ) + case .unparsable: + return "" + } + } + } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift index 3aa434462ab..3331fa5eb34 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift @@ -15,6 +15,7 @@ import Foundation extension STPAPIClient { func lookupConsumerSession( for email: String?, + cookieStore: LinkCookieStore, completion: @escaping (Result) -> Void ) { let endpoint: String = "consumers/sessions/lookup" @@ -25,7 +26,7 @@ extension STPAPIClient { parameters["email_address"] = email.lowercased() } - guard parameters.keys.contains("email_address") || parameters.keys.contains("cookies") else { + guard parameters.keys.contains("email_address") else { // no request to make if we don't have an email or cookies DispatchQueue.main.async { completion(.success( @@ -130,9 +131,36 @@ extension STPAPIClient { ) } + func createPaymentDetails( + for consumerSessionClientSecret: String, + linkedAccountId: String, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + let endpoint: String = "consumers/payment_details" + + let parameters: [String: Any] = [ + "credentials": ["consumer_session_client_secret": consumerSessionClientSecret], + "request_surface": "ios_payment_element", + "bank_account": [ + "account": linkedAccountId, + ], + "type": "bank_account", + "is_default": true, + ] + + makePaymentDetailsRequest( + endpoint: endpoint, + parameters: parameters, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion + ) + } + private func makeConsumerSessionRequest( endpoint: String, parameters: [String: Any], + cookieStore: LinkCookieStore, consumerAccountPublishableKey: String?, completion: @escaping (Result) -> Void ) { @@ -229,8 +257,93 @@ extension STPAPIClient { } } + func listPaymentDetails( + for consumerSessionClientSecret: String, + consumerAccountPublishableKey: String?, + completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void + ) { + let endpoint: String = "consumers/payment_details/list" + + let parameters: [String: Any] = [ + "credentials": ["consumer_session_client_secret": consumerSessionClientSecret], + "request_surface": "ios_payment_element", + "types": ["card", "bank_account"], + ] + + post( + resource: endpoint, + parameters: parameters, + ephemeralKeySecret: consumerAccountPublishableKey + ) { (result: Result) in + completion(result.map { $0.redactedPaymentDetails }) + } + } + + func deletePaymentDetails( + for consumerSessionClientSecret: String, + id: String, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + let endpoint: String = "consumers/payment_details/\(id)" + + let parameters: [String: Any] = [ + "credentials": ["consumer_session_client_secret": consumerSessionClientSecret], + "request_surface": "ios_payment_element", + ] + + APIRequest.delete( + with: self, + endpoint: endpoint, + additionalHeaders: authorizationHeader(using: consumerAccountPublishableKey), + parameters: parameters + ) { result in + completion(result.map { _ in () } ) + } + } + + func updatePaymentDetails( + for consumerSessionClientSecret: String, + id: String, + updateParams: UpdatePaymentDetailsParams, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + let endpoint: String = "consumers/payment_details/\(id)" + + var parameters: [String: Any] = [ + "credentials": ["consumer_session_client_secret": consumerSessionClientSecret], + "request_surface": "ios_payment_element", + ] + + if let details = updateParams.details, case .card(let expiryDate, let billingDetails) = details { + parameters["exp_month"] = expiryDate.month + parameters["exp_year"] = expiryDate.year + + if let billingDetails = billingDetails { + parameters["billing_address"] = billingDetails.consumersAPIParams + } + + if let billingEmailAddress = billingDetails?.email { + parameters["billing_email_address"] = billingEmailAddress + } + } + + if let isDefault = updateParams.isDefault { + parameters["is_default"] = isDefault + } + + makePaymentDetailsRequest( + endpoint: endpoint, + parameters: parameters, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion + ) + } + func logout( consumerSessionClientSecret: String, + cookieStore: LinkCookieStore, consumerAccountPublishableKey: String?, completion: @escaping (Result) -> Void ) { @@ -246,10 +359,72 @@ extension STPAPIClient { makeConsumerSessionRequest( endpoint: endpoint, parameters: parameters, + cookieStore: cookieStore, consumerAccountPublishableKey: consumerAccountPublishableKey, completion: completion ) } + + func startVerification( + for consumerSessionClientSecret: String, + type: ConsumerSession.VerificationSession.SessionType, + locale: Locale, + cookieStore: LinkCookieStore, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + + let typeString: String = { + switch type { + case .sms: + return "SMS" + case .unparsable, .signup, .email: + assertionFailure("We don't support any verification except sms") + return "" + } + }() + let endpoint: String = "consumers/sessions/start_verification" + + let parameters: [String: Any] = [ + "credentials": ["consumer_session_client_secret": consumerSessionClientSecret], + "type": typeString, + "locale": locale.toLanguageTag(), + ] + + makeConsumerSessionRequest( + endpoint: endpoint, + parameters: parameters, + cookieStore: cookieStore, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion + ) + } + + func confirmSMSVerification( + for consumerSessionClientSecret: String, + with code: String, + cookieStore: LinkCookieStore, + consumerAccountPublishableKey: String?, + completion: @escaping (Result) -> Void + ) { + let endpoint: String = "consumers/sessions/confirm_verification" + + let parameters: [String: Any] = [ + "credentials": ["consumer_session_client_secret": consumerSessionClientSecret], + "type": "SMS", + "code": code, + "request_surface": "ios_payment_element", + ] + + makeConsumerSessionRequest( + endpoint: endpoint, + parameters: parameters, + cookieStore: cookieStore, + consumerAccountPublishableKey: consumerAccountPublishableKey, + completion: completion + ) + } + } // TODO(ramont): Remove this after switching to modern bindings. diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Badge/LinkBadgeView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Badge/LinkBadgeView.swift new file mode 100644 index 00000000000..a2c5bc0e10e --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Badge/LinkBadgeView.swift @@ -0,0 +1,133 @@ +// +// LinkBadgeView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 4/29/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeUICore + +/// For internal SDK use only +@objc(STP_Internal_LinkBadgeView) +final class LinkBadgeView: UIView { + struct Constants { + static let spacing: CGFloat = 4 + static let margins: NSDirectionalEdgeInsets = .insets(top: 2, leading: 4, bottom: 2, trailing: 4) + static let iconSize: CGSize = .init(width: 12, height: 12) + static let maxFontSize: CGFloat = 16 + } + + enum BadgeType { + case neutral + case error + } + + let type: BadgeType + + var text: String? { + get { + return textLabel.text + } + set { + textLabel.text = newValue + } + } + + private lazy var iconView: UIImageView? = { + guard let icon = type.icon else { + return nil + } + + let imageView = UIImageView(image: icon) + imageView.tintColor = type.foregroundColor + imageView.setContentHuggingPriority(.required, for: .horizontal) + imageView.setContentCompressionResistancePriority(.required, for: .horizontal) + imageView.isHidden = imageView.image == nil + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: Constants.iconSize.width), + imageView.heightAnchor.constraint(equalToConstant: Constants.iconSize.height), + ]) + + return imageView + }() + + private lazy var textLabel: UILabel = { + let label = UILabel() + label.textColor = type.foregroundColor + label.font = LinkUI.font(forTextStyle: .captionEmphasized, maximumPointSize: Constants.maxFontSize) + label.adjustsFontForContentSizeCategory = true + return label + }() + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: 20) + } + + convenience init(type: BadgeType, text: String) { + self.init(type: type) + self.text = text + } + + init(type: BadgeType) { + self.type = type + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + setContentHuggingPriority(.required, for: .vertical) + setContentHuggingPriority(.required, for: .horizontal) + + let stackView = UIStackView(arrangedSubviews: [iconView, textLabel].compactMap({ $0 })) + + stackView.axis = .horizontal + stackView.spacing = Constants.spacing + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = Constants.margins + addAndPinSubview(stackView) + + backgroundColor = type.backgroundColor + layer.cornerRadius = LinkUI.smallCornerRadius + } + +} + +private extension LinkBadgeView.BadgeType { + + var icon: UIImage? { + switch self { + case .neutral: + return nil + case .error: + return Image.icon_link_error.makeImage(template: true) + } + } + + var backgroundColor: UIColor { + switch self { + case .neutral: + return .linkNeutralBackground + case .error: + return .linkDangerBackground + } + } + + var foregroundColor: UIColor { + switch self { + case .neutral: + return .linkNeutralForeground + case .error: + return .linkDangerForeground + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/NavigationBar/LinkNavigationBar.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/NavigationBar/LinkNavigationBar.swift new file mode 100644 index 00000000000..3bad321528c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/NavigationBar/LinkNavigationBar.swift @@ -0,0 +1,187 @@ +// +// LinkNavigationBar.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 3/10/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore + +/// For internal SDK use only +@objc(STP_Internal_LinkNavigationBar) +final class LinkNavigationBar: UIView { + struct Constants { + static let buttonSize: CGSize = .init(width: 60, height: 44) + static let labelMargin: CGFloat = 20 + static let maxFontSize: CGFloat = 18 + static let logoVerticalOffset: CGFloat = 14 + static let defaultHeight: CGFloat = 44 + static let largeHeight: CGFloat = 66 + } + + var linkAccount: PaymentSheetLinkAccountInfoProtocol? { + didSet { + update() + } + } + + var showBackButton: Bool = false { + didSet { + if showBackButton != oldValue { + update() + } + } + } + + var isLarge: Bool { + // The nav bar is considered large as long as we need to display the email label. + return showEmailLabel + } + + private var showEmailLabel: Bool = false { + didSet { + emailLabel.isHidden = !showEmailLabel + invalidateIntrinsicContentSize() + } + } + + let backButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(Image.back_button.makeImage(), for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = String.Localized.back + button.isHidden = true + return button + }() + + let closeButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(Image.icon_cancel.makeImage(), for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = String.Localized.close + return button + }() + + let menuButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(Image.icon_menu_horizontal.makeImage(), for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = String.Localized.show_menu + return button + }() + + private let logoView: UIImageView = { + let imageView = UIImageView(image: Image.link_logo.makeImage(template: true)) + imageView.tintColor = .linkNavLogo + imageView.isAccessibilityElement = true + imageView.accessibilityTraits = .header + imageView.accessibilityLabel = STPPaymentMethodType.link.displayName + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let emailLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .body, maximumPointSize: Constants.maxFontSize) + label.textColor = .linkTertiaryText + label.textAlignment = .center + label.lineBreakMode = .byTruncatingMiddle + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = true + return label + }() + + override var intrinsicContentSize: CGSize { + let baseHeight: CGFloat = isLarge + ? Constants.largeHeight + : Constants.defaultHeight + return CGSize( + width: UIView.noIntrinsicMetric, + height: baseHeight + safeAreaInsets.top + safeAreaInsets.bottom + ) + } + + init() { + super.init(frame: .zero) + + setContentHuggingPriority(.defaultHigh, for: .vertical) + setContentHuggingPriority(.defaultLow, for: .horizontal) + + tintColor = .linkNavTint + backgroundColor = .linkBackground + + addSubview(logoView) + addSubview(emailLabel) + addSubview(backButton) + addSubview(closeButton) + addSubview(menuButton) + + NSLayoutConstraint.activate([ + // Back button + backButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + backButton.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), + backButton.widthAnchor.constraint(equalToConstant: Constants.buttonSize.width), + backButton.heightAnchor.constraint(equalToConstant: Constants.buttonSize.height), + // Close button + closeButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + closeButton.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), + closeButton.widthAnchor.constraint(equalToConstant: Constants.buttonSize.width), + closeButton.heightAnchor.constraint(equalToConstant: Constants.buttonSize.height), + // Logo + logoView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: Constants.logoVerticalOffset), + logoView.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + logoView.bottomAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.bottomAnchor), + // Email label + emailLabel.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + emailLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + emailLabel.leadingAnchor.constraint( + greaterThanOrEqualTo: safeAreaLayoutGuide.leadingAnchor, + constant: Constants.labelMargin + ), + emailLabel.trailingAnchor.constraint( + lessThanOrEqualTo: safeAreaLayoutGuide.trailingAnchor, + constant: Constants.labelMargin + ), + // Menu button + menuButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + menuButton.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), + menuButton.widthAnchor.constraint(equalToConstant: Constants.buttonSize.width), + menuButton.heightAnchor.constraint(equalToConstant: Constants.buttonSize.height), + ]) + + update() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + invalidateIntrinsicContentSize() + } + + private func update() { + let isLoggedIn = linkAccount?.isLoggedIn ?? false + + emailLabel.text = linkAccount?.email + showEmailLabel = isLoggedIn && !showBackButton + + // Back and close button are mutually exclusive. + backButton.isHidden = !showBackButton + closeButton.isHidden = showBackButton + + // Hide the logo if showing the back button. + logoView.isHidden = showBackButton + + // Menu should be hidden if not logged in or we are currently showing the back button. + menuButton.isHidden = !isLoggedIn || showBackButton + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Notice/LinkNoticeView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Notice/LinkNoticeView.swift new file mode 100644 index 00000000000..f73659ec946 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Notice/LinkNoticeView.swift @@ -0,0 +1,113 @@ +// +// LinkNoticeView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 3/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeUICore + +/// A view for displaying text notices. +/// +/// For internal SDK use only +@objc(STP_Internal_LinkNoticeView) +final class LinkNoticeView: UIView { + struct Constants { + static let spacing: CGFloat = 10 + static let margins: NSDirectionalEdgeInsets = .insets(amount: 12) + } + + enum NoticeType { + case error + } + + let type: NoticeType + + var text: String? { + get { + return textLabel.text + } + set { + textLabel.text = newValue + } + } + + private lazy var iconView: UIImageView = { + let imageView = UIImageView(image: type.icon) + imageView.tintColor = type.foregroundColor + imageView.adjustsImageSizeForAccessibilityContentSizeCategory = true + imageView.setContentHuggingPriority(.required, for: .horizontal) + imageView.setContentCompressionResistancePriority(.required, for: .horizontal) + return imageView + }() + + private lazy var textLabel: UILabel = { + let label = UILabel() + label.textColor = type.foregroundColor + label.numberOfLines = 0 + label.font = LinkUI.font(forTextStyle: .detail) + label.adjustsFontForContentSizeCategory = true + return label + }() + + convenience init(type: NoticeType, text: String) { + self.init(type: type) + self.text = text + } + + init(type: NoticeType) { + self.type = type + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + let stackView = UIStackView(arrangedSubviews: [ + iconView, + textLabel, + ]) + + stackView.axis = .horizontal + stackView.spacing = Constants.spacing + stackView.alignment = .top + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = Constants.margins + addAndPinSubview(stackView) + + backgroundColor = type.backgroundColor + layer.cornerRadius = LinkUI.mediumCornerRadius + } + +} + +private extension LinkNoticeView.NoticeType { + + var icon: UIImage { + switch self { + case .error: + return Image.icon_link_error.makeImage(template: true) + } + } + + var backgroundColor: UIColor { + switch self { + case .error: + return .linkDangerBackground + } + } + + var foregroundColor: UIColor { + switch self { + case .error: + return .linkDangerForeground + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-AddButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-AddButton.swift new file mode 100644 index 00000000000..3bb072a6432 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-AddButton.swift @@ -0,0 +1,99 @@ +// +// LinkPaymentMethodPicker-AddButton.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/20/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +extension LinkPaymentMethodPicker { + + final class AddButton: UIControl { + struct Constants { + static let iconSize: CGSize = .init(width: 24, height: 24) + } + + private let iconView: UIImageView = UIImageView(image: Image.icon_add_bordered.makeImage(template: true)) + + private lazy var textLabel: UILabel = { + let label = UILabel() + label.text = String.Localized.add_a_payment_method + label.numberOfLines = 0 + label.textColor = tintColor + label.font = LinkUI.font(forTextStyle: .bodyEmphasized) + label.adjustsFontForContentSizeCategory = true + return label + }() + + override var isHighlighted: Bool { + didSet { + update() + } + } + + init() { + super.init(frame: .zero) + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityLabel = textLabel.text + directionalLayoutMargins = Cell.Constants.margins + + setupUI() + update() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.point(inside: point, with: event) { + return self + } + + return nil + } + + override func tintColorDidChange() { + super.tintColorDidChange() + self.textLabel.textColor = tintColor + } + + private func setupUI() { + let stackView = UIStackView(arrangedSubviews: [iconView, textLabel]) + stackView.spacing = Cell.Constants.contentSpacing + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stackView) + + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: Constants.iconSize.width), + iconView.heightAnchor.constraint(equalToConstant: Constants.iconSize.height), + stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + private func update() { + if isHighlighted { + iconView.alpha = 0.7 + textLabel.alpha = 0.7 + backgroundColor = .linkControlHighlight + } else { + iconView.alpha = 1 + textLabel.alpha = 1 + backgroundColor = .clear + } + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Cell.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Cell.swift new file mode 100644 index 00000000000..6a2a47fa3bc --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Cell.swift @@ -0,0 +1,280 @@ +// +// LinkPaymentMethodPicker-Cell.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol LinkPaymentMethodPickerCellDelegate: AnyObject { + func savedPaymentPickerCellDidSelect(_ cell: LinkPaymentMethodPicker.Cell) + func savedPaymentPickerCell(_ cell: LinkPaymentMethodPicker.Cell, didTapMenuButton button: UIButton) +} + +extension LinkPaymentMethodPicker { + + final class Cell: UIControl { + struct Constants { + static let margins = NSDirectionalEdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20) + static let contentSpacing: CGFloat = 12 + static let contentIndentation: CGFloat = 34 + static let menuSpacing: CGFloat = 8 + static let menuButtonSize: CGSize = .init(width: 24, height: 24) + static let separatorHeight: CGFloat = 1 + static let iconViewSize: CGSize = .init(width: 14, height: 20) + static let disabledContentAlpha: CGFloat = 0.5 + } + + override var isHighlighted: Bool { + didSet { + setNeedsLayout() + } + } + + override var isSelected: Bool { + didSet { + setNeedsLayout() + } + } + + var paymentMethod: ConsumerPaymentDetails? { + didSet { + update() + } + } + + var isLoading: Bool = false { + didSet { + if isLoading != oldValue { + update() + } + } + } + + var isSupported: Bool = true { + didSet { + if isSupported != oldValue { + update() + } + } + } + + weak var delegate: LinkPaymentMethodPickerCellDelegate? + + private let radioButton = RadioButton() + + private let contentView = CellContentView() + + private let activityIndicator = ActivityIndicator() + + private let defaultBadge = LinkBadgeView( + type: .neutral, + text: STPLocalizedString("Default", "Label for identifying the default payment method.") + ) + + private let alertIconView: UIImageView = { + let iconView = UIImageView() + iconView.contentMode = .scaleAspectFit + iconView.image = Image.icon_link_error.makeImage(template: true) + iconView.tintColor = .linkDangerForeground + return iconView + }() + + private let unavailableBadge = LinkBadgeView(type: .error, text: STPLocalizedString( + "Unavailable for this purchase", + "Label shown when a payment method cannot be used for the current transaction." + )) + + private lazy var menuButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(Image.icon_menu.makeImage(), for: .normal) + button.addTarget(self, action: #selector(onMenuButtonTapped(_:)), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + /// The menu button frame for hit-testing purposes. + private var menuButtonFrame: CGRect { + let originalFrame = menuButton.convert(menuButton.bounds, to: self) + + let targetSize = CGSize(width: 44, height: 44) + + return CGRect( + x: originalFrame.midX - (targetSize.width / 2), + y: originalFrame.midY - (targetSize.height / 2), + width: targetSize.width, + height: targetSize.height + ) + } + + private let separator: UIView = { + let separator = UIView() + separator.backgroundColor = .linkControlBorder + separator.translatesAutoresizingMaskIntoConstraints = false + return separator + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + isAccessibilityElement = true + directionalLayoutMargins = Constants.margins + + setupUI() + addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(onCellLongPressed))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + let rightStackView = UIStackView(arrangedSubviews: [defaultBadge, alertIconView, activityIndicator, menuButton]) + rightStackView.spacing = LinkUI.smallContentSpacing + rightStackView.distribution = .equalSpacing + rightStackView.alignment = .center + + let stackView = UIStackView(arrangedSubviews: [contentView, rightStackView]) + stackView.spacing = Constants.contentSpacing + stackView.distribution = .equalSpacing + stackView.alignment = .center + + let container = UIStackView(arrangedSubviews: [stackView, unavailableBadge]) + container.axis = .vertical + container.spacing = Constants.contentSpacing + container.distribution = .equalSpacing + container.alignment = .leading + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + + radioButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(radioButton) + + addSubview(separator) + + NSLayoutConstraint.activate([ + // Radio button + radioButton.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + radioButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + // Menu button + menuButton.widthAnchor.constraint(equalToConstant: Constants.menuButtonSize.width), + menuButton.heightAnchor.constraint(equalToConstant: Constants.menuButtonSize.height), + + // Loader + activityIndicator.widthAnchor.constraint(equalToConstant: Constants.menuButtonSize.width), + activityIndicator.heightAnchor.constraint(equalToConstant: Constants.menuButtonSize.height), + + // Icon + alertIconView.widthAnchor.constraint(equalToConstant: Constants.iconViewSize.width), + alertIconView.heightAnchor.constraint(equalToConstant: Constants.iconViewSize.height), + + // Container + container.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + container.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + container.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor, constant: Constants.contentIndentation), + container.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + // Make stackView fill the container + stackView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + + // Separator + separator.heightAnchor.constraint(equalToConstant: Constants.separatorHeight), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + private func update() { + contentView.paymentMethod = paymentMethod + updateAccessibilityContent() + + guard let paymentMethod = paymentMethod else { + return + } + + var hasExpired: Bool { + switch paymentMethod.details { + case .card(let card): + return card.hasExpired + case .bankAccount: + return false + case .unparsable: + return false + } + } + + defaultBadge.isHidden = isLoading || !paymentMethod.isDefault + alertIconView.isHidden = isLoading || !hasExpired + menuButton.isHidden = isLoading + contentView.alpha = isSupported ? 1 : Constants.disabledContentAlpha + unavailableBadge.isHidden = isSupported + + if isLoading { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + radioButton.isOn = isSelected + backgroundColor = isHighlighted ? .linkControlHighlight : .clear + } + + private func updateAccessibilityContent() { + guard let paymentMethod = paymentMethod else { + return + } + + accessibilityIdentifier = "Stripe.Link.PaymentMethodPickerCell" + accessibilityLabel = paymentMethod.accessibilityDescription + accessibilityCustomActions = [ + UIAccessibilityCustomAction( + name: String.Localized.show_menu, + target: self, + selector: #selector(onShowMenuAction(_:)) + ), + ] + } + + @objc func onShowMenuAction(_ sender: UIAccessibilityCustomAction) { + onMenuButtonTapped(menuButton) + } + + @objc func onMenuButtonTapped(_ sender: UIButton) { + delegate?.savedPaymentPickerCell(self, didTapMenuButton: sender) + } + + @objc func onCellLongPressed() { + delegate?.savedPaymentPickerCell(self, didTapMenuButton: menuButton) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if menuButtonFrame.contains(point) { + return menuButton + } + + return bounds.contains(point) ? self : nil + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + + guard let touchLocation = touches.first?.location(in: self) else { + return + } + + if bounds.contains(touchLocation) { + delegate?.savedPaymentPickerCellDidSelect(self) + } + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-CellContentView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-CellContentView.swift new file mode 100644 index 00000000000..5fe2fe5bcc5 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-CellContentView.swift @@ -0,0 +1,138 @@ +// +// LinkPaymentMethodPicker-CellContentView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +extension LinkPaymentMethodPicker { + + final class CellContentView: UIView { + struct Constants { + static let contentSpacing: CGFloat = 12 + static let iconSize: CGSize = CardBrandView.targetIconSize + static let maxFontSize: CGFloat = 20 + } + + var paymentMethod: ConsumerPaymentDetails? { + didSet { + switch paymentMethod?.details { + case .card: +// TODO(link): Needs refactor +// case .card(let card): +// cardBrandView.cardBrand = card.stpBrand + bankIconView.isHidden = true + cardBrandView.isHidden = false + primaryLabel.text = paymentMethod?.paymentSheetLabel + secondaryLabel.text = nil + secondaryLabel.isHidden = true + case .bankAccount(let bankAccount): + bankIconView.image = PaymentSheetImageLibrary.bankIcon(for: bankAccount.iconCode) + cardBrandView.isHidden = true + bankIconView.isHidden = false + primaryLabel.text = bankAccount.name + secondaryLabel.text = paymentMethod?.paymentSheetLabel + secondaryLabel.isHidden = false + case .none, .unparsable: + cardBrandView.isHidden = true + bankIconView.isHidden = true + primaryLabel.text = nil + secondaryLabel.text = nil + secondaryLabel.isHidden = true + } + } + } + + private lazy var bankIconView: UIImageView = { + let iconView = UIImageView() + iconView.contentMode = .scaleAspectFit + return iconView + }() + + private lazy var cardBrandView: CardBrandView = CardBrandView(centerHorizontally: true) + + private let primaryLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = LinkUI.font(forTextStyle: .bodyEmphasized, maximumPointSize: Constants.maxFontSize) + label.textColor = .linkPrimaryText + return label + }() + + private let secondaryLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .caption, maximumPointSize: Constants.maxFontSize) + label.textColor = .linkSecondaryText + return label + }() + + private lazy var iconContainerView: UIView = { + let view = UIView() + bankIconView.translatesAutoresizingMaskIntoConstraints = false + cardBrandView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(bankIconView) + view.addSubview(cardBrandView) + + let cardBrandSize = cardBrandView.size(for: Constants.iconSize) + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: max(Constants.iconSize.width, cardBrandSize.width)), + view.heightAnchor.constraint(equalToConstant: max(Constants.iconSize.height, cardBrandSize.height)), + + bankIconView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), + bankIconView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor), + bankIconView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor), + bankIconView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor), + bankIconView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + bankIconView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + cardBrandView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), + cardBrandView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor), + cardBrandView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor), + cardBrandView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor), + cardBrandView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + cardBrandView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + cardBrandView.widthAnchor.constraint(equalToConstant: cardBrandSize.width), + cardBrandView.heightAnchor.constraint(equalToConstant: cardBrandSize.height), + ]) + + return view + }() + + private lazy var stackView: UIStackView = { + let labelStackView = UIStackView(arrangedSubviews: [ + primaryLabel, + secondaryLabel, + ]) + labelStackView.axis = .vertical + labelStackView.alignment = .leading + labelStackView.spacing = 0 + + let stackView = UIStackView(arrangedSubviews: [ + iconContainerView, + labelStackView, + ]) + + stackView.spacing = Constants.contentSpacing + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addAndPinSubview(stackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Header.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Header.swift new file mode 100644 index 00000000000..3064dd16451 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-Header.swift @@ -0,0 +1,194 @@ +// +// LinkPaymentMethodPicker-Header.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/22/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +extension LinkPaymentMethodPicker { + + final class Header: UIControl { + struct Constants { + static let contentSpacing: CGFloat = 16 + static let chevronSize: CGSize = .init(width: 24, height: 24) + static let collapsedInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20) + static let expandedInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 4, trailing: 20) + } + + /// The selected payment method. + var selectedPaymentMethod: ConsumerPaymentDetails? { + didSet { + contentView.paymentMethod = selectedPaymentMethod + updateAccessibilityContent() + } + } + + var isExpanded: Bool = false { + didSet { + setNeedsLayout() + updateChevron() + updateAccessibilityContent() + } + } + + override var isHighlighted: Bool { + didSet { + if isHighlighted && !isExpanded { + backgroundColor = .linkControlHighlight + } else { + backgroundColor = .clear + } + } + } + + private let payWithLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .body) + label.textColor = .linkSecondaryText + label.text = STPLocalizedString("Payment", "Label for a section displaying payment details.") + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let headingLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .bodyEmphasized) + label.textColor = .linkPrimaryText + label.text = STPLocalizedString( + "Payment methods", + "Title for a section listing one or more payment methods." + ) + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let contentView = CellContentView() + + private let cardNumberLabel: UILabel = { + let label = UILabel() + label.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .medium) + .scaled(withTextStyle: .body, maximumPointSize: 20) + return label + }() + + private lazy var chevron: UIImageView = { + let chevron = UIImageView(image: StripeUICore.Image.icon_chevron_down.makeImage(template: true)) + chevron.contentMode = .center + + NSLayoutConstraint.activate([ + chevron.widthAnchor.constraint(equalToConstant: Constants.chevronSize.width), + chevron.heightAnchor.constraint(equalToConstant: Constants.chevronSize.height), + ]) + + return chevron + }() + + private lazy var paymentInfoStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + payWithLabel, + contentView, + ]) + + stackView.alignment = .center + stackView.setCustomSpacing(Constants.contentSpacing, after: payWithLabel) + stackView.translatesAutoresizingMaskIntoConstraints = false + + return stackView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + paymentInfoStackView, + headingLabel, + chevron, + ]) + + stackView.axis = .horizontal + stackView.spacing = Constants.contentSpacing + stackView.distribution = .equalSpacing + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.translatesAutoresizingMaskIntoConstraints = false + + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + + addSubview(stackView) + + NSLayoutConstraint.activate([ + // Stack view + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + isAccessibilityElement = true + accessibilityTraits = .button + + updateChevron() + updateAccessibilityContent() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateChevron() { + if isExpanded { + chevron.transform = CGAffineTransform(rotationAngle: .pi) + chevron.tintColor = .linkPrimaryText + } else { + chevron.transform = .identity + chevron.tintColor = .linkSecondaryText + } + } + + private func updateAccessibilityContent() { + if isExpanded { + accessibilityLabel = headingLabel.text + accessibilityHint = STPLocalizedString( + "Tap to close", + "Accessibility hint to tell the user that they can tap to hide additional content." + ) + } else { + accessibilityLabel = selectedPaymentMethod?.accessibilityDescription + accessibilityHint = STPLocalizedString( + "Tap to expand", + "Accessibility hint to tell the user that they can tap to reveal additional content." + ) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + if isExpanded { + paymentInfoStackView.isHidden = true + headingLabel.isHidden = false + stackView.directionalLayoutMargins = Constants.expandedInsets + } else { + paymentInfoStackView.isHidden = false + headingLabel.isHidden = true + stackView.directionalLayoutMargins = Constants.collapsedInsets + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.point(inside: point, with: event) { + return self + } + + return nil + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-RadioButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-RadioButton.swift new file mode 100644 index 00000000000..b2078ae3046 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-RadioButton.swift @@ -0,0 +1,116 @@ +// +// LinkPaymentMethodPicker-RadioButton.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/15/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension LinkPaymentMethodPicker { + + final class RadioButton: UIView { + struct Constants { + static let diameter: CGFloat = 20 + static let innerDiameter: CGFloat = 8 + static let borderWidth: CGFloat = 1 + } + + public var isOn: Bool = false { + didSet { + update() + } + } + + public var borderColor: UIColor = .linkControlBorder { + didSet { + applyStyling() + } + } + + /// Layer for the "off" state. + private let offLayer: CALayer = { + let layer = CALayer() + layer.bounds = CGRect(x: 0, y: 0, width: Constants.diameter, height: Constants.diameter) + layer.cornerRadius = Constants.diameter / 2 + layer.borderWidth = Constants.borderWidth + layer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + return layer + }() + + /// Layer for the "on" state. + private let onLayer: CALayer = { + let layer = CALayer() + layer.bounds = CGRect(x: 0, y: 0, width: Constants.diameter, height: Constants.diameter) + layer.cornerRadius = Constants.diameter / 2 + layer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + let innerCircle = CALayer() + innerCircle.backgroundColor = UIColor.white.cgColor + innerCircle.cornerRadius = Constants.innerDiameter / 2 + innerCircle.bounds = CGRect(x: 0, y: 0, width: Constants.innerDiameter, height: Constants.innerDiameter) + innerCircle.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + // Add and center inner circle + layer.addSublayer(innerCircle) + innerCircle.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY) + + return layer + }() + + override var intrinsicContentSize: CGSize { + return CGSize(width: Constants.diameter, height: Constants.diameter) + } + + init() { + super.init(frame: .zero) + layer.addSublayer(offLayer) + layer.addSublayer(onLayer) + update() + applyStyling() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + applyStyling() + } + + override func layoutSubviews() { + offLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + onLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + } + + override func tintColorDidChange() { + super.tintColorDidChange() + applyStyling() + } + +#if !os(visionOS) + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + applyStyling() + } +#endif + + // MARK: - Private methods + + private func update() { + CATransaction.begin() + CATransaction.setDisableActions(true) + offLayer.isHidden = isOn + onLayer.isHidden = !isOn + CATransaction.commit() + } + + private func applyStyling() { + CATransaction.begin() + CATransaction.setDisableActions(true) + offLayer.borderColor = borderColor.cgColor + onLayer.backgroundColor = tintColor.cgColor + CATransaction.commit() + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift new file mode 100644 index 00000000000..805777205e4 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift @@ -0,0 +1,320 @@ +// +// LinkPaymentMethodPicker.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/25/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeUICore + +protocol LinkPaymentMethodPickerDelegate: AnyObject { + func paymentMethodPickerDidChange(_ picker: LinkPaymentMethodPicker) + + func paymentMethodPicker( + _ picker: LinkPaymentMethodPicker, + showMenuForItemAt index: Int, + sourceRect: CGRect + ) + + func paymentDetailsPickerDidTapOnAddPayment(_ picker: LinkPaymentMethodPicker) +} + +protocol LinkPaymentMethodPickerDataSource: AnyObject { + + /// Returns the total number of payment methods. + /// - Returns: Payment method count + func numberOfPaymentMethods(in picker: LinkPaymentMethodPicker) -> Int + + /// Returns the payment method at the specific index. + /// - Returns: Payment method. + func paymentPicker( + _ picker: LinkPaymentMethodPicker, + paymentMethodAt index: Int + ) -> ConsumerPaymentDetails + +} + +/// For internal SDK use only +@objc(STP_Internal_LinkPaymentMethodPicker) +final class LinkPaymentMethodPicker: UIView { + weak var delegate: LinkPaymentMethodPickerDelegate? + weak var dataSource: LinkPaymentMethodPickerDataSource? + + var selectedIndex: Int = 0 { + didSet { + updateHeaderView() + } + } + + var supportedPaymentMethodTypes = Set(ConsumerPaymentDetails.DetailsType.allCases) + + var selectedPaymentMethod: ConsumerPaymentDetails? { + let count = dataSource?.numberOfPaymentMethods(in: self) ?? 0 + + guard selectedIndex >= 0 && selectedIndex < count else { + return nil + } + + return dataSource?.paymentPicker(self, paymentMethodAt: selectedIndex) + } + + private var needsDataReload: Bool = true + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + headerView, + listView, + ]) + + stackView.axis = .vertical + stackView.clipsToBounds = true + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private let headerView = Header() + + private lazy var listView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + addPaymentMethodButton + ]) + + stackView.axis = .vertical + stackView.distribution = .equalSpacing + stackView.clipsToBounds = true + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + #if !os(visionOS) + private let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + private let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + #endif + + private let addPaymentMethodButton = AddButton() + + override init(frame: CGRect) { + super.init(frame: .zero) + addAndPinSubview(stackView) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + clipsToBounds = true + accessibilityIdentifier = "Stripe.Link.PaymentMethodPicker" + + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = UIColor.linkControlBorder.cgColor + tintColor = .linkBrandDark + backgroundColor = .linkControlBackground + + headerView.addTarget(self, action: #selector(onHeaderTapped(_:)), for: .touchUpInside) + headerView.layer.zPosition = 1 + + listView.isHidden = true + listView.layer.zPosition = 0 + + addPaymentMethodButton.addTarget(self, action: #selector(onAddPaymentButtonTapped(_:)), for: .touchUpInside) + } + + override func layoutSubviews() { + super.layoutSubviews() + reloadDataIfNeeded() + } + +#if !os(visionOS) + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + layer.borderColor = UIColor.linkControlBorder.cgColor + } +#endif + + func setExpanded(_ expanded: Bool, animated: Bool) { + headerView.isExpanded = expanded + + // Prevent double header animation + if headerView.isExpanded { + // TODO(ramont): revise layout margin placement and remove conditional + setNeedsLayout() + layoutIfNeeded() + } else { + headerView.layoutIfNeeded() + } + + if headerView.isExpanded { + stackView.showArrangedSubview(at: 1, animated: animated) + } else { + stackView.hideArrangedSubview(at: 1, animated: animated) + } + } + + private func updateHeaderView() { + headerView.selectedPaymentMethod = selectedPaymentMethod + } + +} + +private extension LinkPaymentMethodPicker { + + @objc func onHeaderTapped(_ sender: Header) { + setExpanded(!sender.isExpanded, animated: true) +#if !os(visionOS) + impactFeedbackGenerator.impactOccurred() +#endif + } + + @objc func onAddPaymentButtonTapped(_ sender: AddButton) { + delegate?.paymentDetailsPickerDidTapOnAddPayment(self) + } + +} + +// MARK: - Data Loading + +extension LinkPaymentMethodPicker { + + func reloadData() { + needsDataReload = false + + addMissingPaymentMethodCells() + + let count = dataSource?.numberOfPaymentMethods(in: self) ?? 0 + if count == 0 { + headerView.isHidden = true + listView.isHidden = false + } + + for index in 0.. listView.arrangedSubviews.count - 1 { + let cell = Cell() + cell.delegate = self + + let index = listView.arrangedSubviews.count - 1 + listView.insertArrangedSubview(cell, at: index) + } + + for (index, subview) in listView.arrangedSubviews.enumerated() { + subview.layer.zPosition = CGFloat(-index) + } + + headerView.selectedPaymentMethod = selectedPaymentMethod + } + +} + +extension LinkPaymentMethodPicker { + + func index(for cell: Cell) -> Int? { + return listView.arrangedSubviews.firstIndex(of: cell) + } + + func removePaymentMethod(at index: Int, animated: Bool) { + isUserInteractionEnabled = false + + listView.removeArrangedSubview(at: index, animated: true) { + let count = self.dataSource?.numberOfPaymentMethods(in: self) ?? 0 + + if index < self.selectedIndex { + self.selectedIndex = max(self.selectedIndex - 1, 0) + } + + self.selectedIndex = max(min(self.selectedIndex, count - 1), 0) + + self.reloadData() + self.delegate?.paymentMethodPickerDidChange(self) + self.isUserInteractionEnabled = true + } + } + +} + +// MARK: - Cell delegate + +extension LinkPaymentMethodPicker: LinkPaymentMethodPickerCellDelegate { + + func savedPaymentPickerCellDidSelect(_ savedCardView: Cell) { + if let newIndex = index(for: savedCardView) { + let oldIndex = selectedIndex + selectedIndex = newIndex + + reloadCell(at: oldIndex) + reloadCell(at: newIndex) + +#if !os(visionOS) + selectionFeedbackGenerator.selectionChanged() +#endif + + delegate?.paymentMethodPickerDidChange(self) + } + } + + func savedPaymentPickerCell(_ cell: Cell, didTapMenuButton button: UIButton) { + guard let index = index(for: cell) else { + assertionFailure("Index not found") + return + } + + let sourceRect = button.convert(button.bounds, to: self) + + delegate?.paymentMethodPicker(self, showMenuForItemAt: index, sourceRect: sourceRect) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Toast/LinkToast.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Toast/LinkToast.swift new file mode 100644 index 00000000000..2eac4eac24a --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/Toast/LinkToast.swift @@ -0,0 +1,187 @@ +// +// LinkToast.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/23/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore + +/// A view for displaying a brief message to the user. +/// For internal SDK use only +@objc(STP_Internal_LinkToast) +final class LinkToast: UIView { + struct Constants { + static let padding: CGFloat = 12 + /// Space between the icon and label. + static let spacing: CGFloat = 8 + static let animationDuration: TimeInterval = 0.2 + static let animationTravelDistance: CGFloat = 40 + static let defaultDuration: TimeInterval = 2 + } + + enum ToastType { + case success + } + + let toastType: ToastType + + let text: String + + private let iconView = UIImageView() + + private let label: UILabel = { + let label = UILabel() + label.textColor = .linkToastForeground + label.font = LinkUI.font(forTextStyle: .detail, maximumPointSize: 20) + return label + }() + #if !os(visionOS) + private let feedbackGenerator = UINotificationFeedbackGenerator() + #endif + + /// Creates a new toast. + /// - Parameters: + /// - type: Toast type. + /// - text: Text to show. + init(type: ToastType, text: String) { + self.toastType = type + self.text = text + super.init(frame: .zero) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + backgroundColor = .linkToastBackground + directionalLayoutMargins = .insets(amount: Constants.padding) + + insetsLayoutMarginsFromSafeArea = false + + label.text = text + iconView.image = toastType.icon + iconView.tintColor = toastType.iconColor + + let stackView = UIStackView(arrangedSubviews: [iconView, label]) + stackView.spacing = Constants.spacing + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + +} + +// MARK: - Show/Hide + +extension LinkToast { + + /// Show the toast from the given view as context. + /// - Parameters: + /// - view: View to show the toast from. + /// - duration: How long to show the toast for. + func show(from view: UIView, duration: TimeInterval = Constants.defaultDuration) { + let presentingView = view.window ?? view + + translatesAutoresizingMaskIntoConstraints = false + presentingView.addSubview(self) + + NSLayoutConstraint.activate([ + // Horizontally center + centerXAnchor.constraint(equalTo: presentingView.safeAreaLayoutGuide.centerXAnchor), + + // Pin edges + topAnchor.constraint(equalTo: presentingView.safeAreaLayoutGuide.topAnchor), + leadingAnchor.constraint(greaterThanOrEqualTo: presentingView.layoutMarginsGuide.leadingAnchor), + trailingAnchor.constraint(lessThanOrEqualTo: presentingView.layoutMarginsGuide.trailingAnchor), + ]) + + alpha = 0 + transform = CGAffineTransform(translationX: 0, y: -Constants.animationTravelDistance) + + UIView.animate( + withDuration: Constants.animationDuration, + delay: 0, + options: .curveEaseOut + ) { + self.alpha = 1 + self.transform = .identity + } + + UIAccessibility.post(notification: .announcement, argument: text) + #if !os(visionOS) + generateHapticFeedback() + #endif + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + self.hide() + } + } + + /// Hides toast. + /// + /// You normally don't need to call this, as the toast will hide on its own after an specific duration. + func hide() { + guard superview != nil else { + return + } + + UIView.animate( + withDuration: Constants.animationDuration, + delay: 0, + options: .curveEaseOut + ) { + self.alpha = 0 + self.transform = CGAffineTransform(translationX: 0, y: -Constants.animationTravelDistance) + } completion: { _ in + self.removeFromSuperview() + } + } + +#if !os(visionOS) + private func generateHapticFeedback() { + switch toastType { + case .success: + feedbackGenerator.notificationOccurred(.success) + } + } +#endif + +} + +extension LinkToast.ToastType { + + var icon: UIImage { + switch self { + case .success: + return Image.icon_link_success.makeImage(template: true) + } + } + + var iconColor: UIColor { + switch self { + case .success: + return .linkBrand + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-BaseViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-BaseViewController.swift new file mode 100644 index 00000000000..111c9df128c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-BaseViewController.swift @@ -0,0 +1,126 @@ +// +// PayWithLinkViewController-BaseViewController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/2/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +extension PayWithLinkViewController { + + /// For internal SDK use only + @objc(STP_Internal_PayWithLinkBaseViewController) + class BaseViewController: UIViewController { + weak var coordinator: PayWithLinkCoordinating? + + let context: Context + + var preferredContentMargins: NSDirectionalEdgeInsets { + return customNavigationBar.isLarge + ? LinkUI.contentMarginsWithLargeNav + : LinkUI.contentMargins + } + + private(set) lazy var customNavigationBar: LinkNavigationBar = { + let navigationBar = LinkNavigationBar() + navigationBar.backButton.addTarget( + self, + action: #selector(onBackButtonTapped(_:)), + for: .touchUpInside + ) + navigationBar.closeButton.addTarget( + self, + action: #selector(onCloseButtonTapped(_:)), + for: .touchUpInside + ) + navigationBar.menuButton.addTarget( + self, + action: #selector(onMenuButtonTapped(_:)), + for: .touchUpInside + ) + return navigationBar + }() + + private(set) lazy var contentView = UIView() + + init(context: Context) { + self.context = context + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .linkBackground + + customNavigationBar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(customNavigationBar) + + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(contentView) + + NSLayoutConstraint.activate([ + // Navigation bar + customNavigationBar.topAnchor.constraint(equalTo: view.topAnchor), + customNavigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + customNavigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + // Content view + contentView.topAnchor.constraint(equalTo: customNavigationBar.bottomAnchor), + contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func present( + _ viewControllerToPresent: UIViewController, + animated flag: Bool, + completion: (() -> Void)? = nil + ) { + // Any view controller presented by this controller should also be customized. + context.configuration.style.configure(viewControllerToPresent) + super.present(viewControllerToPresent, animated: flag, completion: completion) + } + + @objc + func onBackButtonTapped(_ sender: UIButton) { + navigationController?.popViewController(animated: true) + } + + @objc + func onCloseButtonTapped(_ sender: UIButton) { + if context.shouldFinishOnClose { + coordinator?.finish(withResult: .canceled) + } else { + coordinator?.cancel() + } + } + + @objc + func onMenuButtonTapped(_ sender: UIButton) { + let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + actionSheet.addAction(UIAlertAction( + title: STPLocalizedString("Log out of Link", "Title of the logout action."), + style: .destructive, + handler: { [weak self] _ in + self?.coordinator?.logout(cancel: true) + } + )) + actionSheet.addAction(UIAlertAction(title: String.Localized.cancel, style: .cancel)) + + // iPad support + actionSheet.popoverPresentationController?.sourceView = sender + actionSheet.popoverPresentationController?.sourceRect = sender.bounds + + present(actionSheet, animated: true) + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-LoaderViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-LoaderViewController.swift new file mode 100644 index 00000000000..21488a2ae5a --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-LoaderViewController.swift @@ -0,0 +1,38 @@ +// +// PayWithLinkViewController-LoaderViewController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/2/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeUICore + +extension PayWithLinkViewController { + + /// A view controller that manages and displays a loading indicator + final class LoaderViewController: BaseViewController { + private let activityIndicator = ActivityIndicator(size: .large) + + override func viewDidLoad() { + super.viewDidLoad() + + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + activityIndicator.startAnimating() + } + + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-NewPaymentViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-NewPaymentViewController.swift new file mode 100644 index 00000000000..b6daad6254f --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-NewPaymentViewController.swift @@ -0,0 +1,324 @@ +// +// PayWithLinkViewController-NewPaymentViewController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/2/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import PassKit +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore +import UIKit + +extension PayWithLinkViewController { + + /// For internal SDK use only + @objc(STP_Internal_NewPaymentViewController) + final class NewPaymentViewController: BaseViewController { + struct Constants { + static let applePayButtonHeight: CGFloat = 48 + } + + let linkAccount: PaymentSheetLinkAccount + let isAddingFirstPaymentMethod: Bool + + private lazy var errorLabel: UILabel = { + return ElementsUI.makeErrorLabel(theme: LinkUI.appearance.asElementsTheme) + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .title) + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + label.textAlignment = .center + label.text = String.Localized.add_a_payment_method + return label + }() + + private lazy var confirmButton: ConfirmButton = .makeLinkButton( + callToAction: context.callToAction, + // Use a compact button if we are also displaying the Apple Pay button. + compact: shouldShowApplePayButton + ) { [weak self] in + self?.confirm() + } + + private lazy var cancelButton: Button = { + let buttonTitle = isAddingFirstPaymentMethod + ? String.Localized.pay_another_way + : String.Localized.cancel + + let configuration: Button.Configuration = shouldShowApplePayButton + ? .linkPlain() + : .linkSecondary() + + let button = Button(configuration: configuration, title: buttonTitle) + button.addTarget(self, action: #selector(cancelButtonTapped(_:)), for: .touchUpInside) + return button + }() + + private lazy var separator = SeparatorLabel(text: String.Localized.or) + + private lazy var applePayButton: PKPaymentButton = { + let button = PKPaymentButton(paymentButtonType: .plain, paymentButtonStyle: .compatibleAutomatic) + button.addTarget(self, action: #selector(applePayButtonTapped(_:)), for: .touchUpInside) + button.cornerRadius = LinkUI.cornerRadius + + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.applePayButtonHeight) + ]) + + return button + }() + + private lazy var buttonContainer: UIStackView = { + let vStack = UIStackView(arrangedSubviews: [confirmButton]) + vStack.axis = .vertical + vStack.spacing = LinkUI.contentSpacing + + if shouldShowApplePayButton { + vStack.addArrangedSubview(separator) + vStack.addArrangedSubview(applePayButton) + } + + vStack.addArrangedSubview(cancelButton) + return vStack + }() + + private lazy var addPaymentMethodVC: AddPaymentMethodViewController = { + var configuration = context.configuration + configuration.linkPaymentMethodsOnly = true + configuration.appearance = LinkUI.appearance + + return AddPaymentMethodViewController( + intent: context.intent, + // TODO(link): Update elementsSession + elementsSession: .makeBackupElementsSession(allResponseFields: [:], paymentMethodTypes: []), + configuration: configuration, + // TODO(link): Update formCache and analyticsHelper + formCache: .init(), + analyticsHelper: .init(isCustom: false, configuration: configuration), + delegate: self + ) + }() + + #if !os(visionOS) + private let feedbackGenerator = UINotificationFeedbackGenerator() + #endif + + private var shouldShowApplePayButton: Bool { + return ( + isAddingFirstPaymentMethod && + context.shouldOfferApplePay && + context.configuration.isApplePayEnabled + ) + } + + init( + linkAccount: PaymentSheetLinkAccount, + context: Context, + isAddingFirstPaymentMethod: Bool + ) { + self.linkAccount = linkAccount + self.isAddingFirstPaymentMethod = isAddingFirstPaymentMethod + super.init(context: context) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + addChild(addPaymentMethodVC) + + view.backgroundColor = .linkBackground + + addPaymentMethodVC.view.backgroundColor = .clear + errorLabel.isHidden = true + + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + addPaymentMethodVC.view, + errorLabel, + buttonContainer, + ]) + + stackView.axis = .vertical + stackView.spacing = LinkUI.contentSpacing + stackView.alignment = .center + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: titleLabel) + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: addPaymentMethodVC.view) + stackView.translatesAutoresizingMaskIntoConstraints = false + + let scrollView = LinkKeyboardAvoidingScrollView() + #if !os(visionOS) + scrollView.keyboardDismissMode = .interactive + #endif + scrollView.addSubview(stackView) + + contentView.addAndPinSubview(scrollView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: preferredContentMargins.top), + stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -preferredContentMargins.bottom), + stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + + titleLabel.leadingAnchor.constraint( + equalTo: stackView.safeAreaLayoutGuide.leadingAnchor, + constant: preferredContentMargins.leading), + titleLabel.trailingAnchor.constraint( + equalTo: stackView.safeAreaLayoutGuide.trailingAnchor, + constant: -preferredContentMargins.trailing), + + addPaymentMethodVC.view.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + addPaymentMethodVC.view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + + buttonContainer.leadingAnchor.constraint( + equalTo: stackView.safeAreaLayoutGuide.leadingAnchor, + constant: LinkUI.contentMargins.leading), + buttonContainer.trailingAnchor.constraint( + equalTo: stackView.safeAreaLayoutGuide.trailingAnchor, + constant: -LinkUI.contentMargins.trailing), + ]) + + didUpdate(addPaymentMethodVC) + } + + func confirm() { + updateErrorLabel(for: nil) + + // Dismiss keyboard + view.endEditing(true) + + if addPaymentMethodVC.selectedPaymentMethodType == .instantDebits { + didSelectAddBankAccount() + return + } + + guard let newPaymentOption = addPaymentMethodVC.paymentOption, + case .new(let confirmParams) = newPaymentOption else { + assertionFailure() + return + } + + #if !os(visionOS) + feedbackGenerator.prepare() + #endif + confirmButton.update(state: .processing) + + linkAccount.createPaymentDetails(with: confirmParams.paymentMethodParams) { [weak self] result in + guard let self = self else { + return + } + + switch result { + case .success(let paymentDetails): + if case .card(let card) = paymentDetails.details { + card.cvc = confirmParams.paymentMethodParams.card?.cvc + } + + self.coordinator?.confirm(with: self.linkAccount, + paymentDetails: paymentDetails, + completion: { [weak self] result in + let state: ConfirmButton.Status + + switch result { + case .completed: + state = .succeeded + case .canceled: + state = .enabled + case .failed(let error): + state = .enabled + self?.updateErrorLabel(for: error) + } + + #if !os(visionOS) + self?.feedbackGenerator.notificationOccurred(.success) + #endif + self?.confirmButton.update(state: state, animated: true) { + if state == .succeeded { + self?.coordinator?.finish(withResult: result) + } + } + }) + case .failure(let error): + #if !os(visionOS) + self.feedbackGenerator.notificationOccurred(.error) + #endif + self.confirmButton.update(state: .enabled, animated: true) + self.updateErrorLabel(for: error) + } + } + } + + func didSelectAddBankAccount() { + confirmButton.update(state: .processing) + + coordinator?.startInstantDebits { [weak self] result in + guard let self = self else { return } + + switch result { + case .success: + break + case .failure(let error): + switch error { + case InstantDebitsOnlyAuthenticationSessionManager.Error.canceled: + self.confirmButton.update(state: .enabled) + default: + self.updateErrorLabel(for: error) + self.confirmButton.update(state: .enabled) + } + } + } + } + + func updateErrorLabel(for error: Error?) { + errorLabel.text = error?.nonGenericDescription + UIView.animate(withDuration: PaymentSheetUI.defaultAnimationDuration) { + self.errorLabel.setHiddenIfNecessary(error == nil) + } + } + + @objc + func applePayButtonTapped(_ sender: PKPaymentButton) { + coordinator?.confirmWithApplePay() + } + + @objc + func cancelButtonTapped(_ sender: Button) { + if isAddingFirstPaymentMethod { + coordinator?.cancel() + } else { + navigationController?.popViewController(animated: true) + } + } + + } + +} + +extension PayWithLinkViewController.NewPaymentViewController: AddPaymentMethodViewControllerDelegate { + + func didUpdate(_ viewController: AddPaymentMethodViewController) { + if viewController.selectedPaymentMethodType == .instantDebits { + confirmButton.update(state: .enabled, style: .stripe, callToAction: .add(paymentMethodType: .instantDebits)) + } else { + confirmButton.update( + state: viewController.paymentOption != nil ? .enabled : .disabled, + callToAction: context.callToAction + ) + } + updateErrorLabel(for: nil) + } + + func shouldOfferLinkSignup(_ viewController: AddPaymentMethodViewController) -> Bool { + return false + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-SignUpViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-SignUpViewController.swift new file mode 100644 index 00000000000..054cb4b0cd4 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-SignUpViewController.swift @@ -0,0 +1,317 @@ +// +// PayWithLinkViewController-SignUpViewController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/2/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import SafariServices +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore + +extension PayWithLinkViewController { + + /// For internal SDK use only + @objc(STP_Internal_PayWithLinkSignUpViewController) + final class SignUpViewController: BaseViewController { + + private let viewModel: SignUpViewModel + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .title) + label.textColor = .linkPrimaryText + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + label.textAlignment = .center + label.text = STPLocalizedString( + "Secure 1⁠-⁠click checkout", + "Title for the Link signup screen" + ) + return label + }() + + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .body) + label.textColor = .linkSecondaryText + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + label.textAlignment = .center + label.text = String.Localized.pay_faster_at_$merchant_and_thousands_of_merchants( + merchantDisplayName: context.configuration.merchantDisplayName + ) + return label + }() + + private lazy var emailElement = LinkEmailElement(defaultValue: viewModel.emailAddress, showLogo: true, theme: LinkUI.appearance.asElementsTheme) + + private lazy var phoneNumberElement = PhoneNumberElement( + defaultCountryCode: context.configuration.defaultBillingDetails.address.country, + defaultPhoneNumber: context.configuration.defaultBillingDetails.phone, + theme: LinkUI.appearance.asElementsTheme + ) + + private lazy var nameElement = TextFieldElement( + configuration: TextFieldElement.NameConfiguration( + type: .full, + defaultValue: viewModel.legalName + ), + theme: LinkUI.appearance.asElementsTheme + ) + + private lazy var emailSection = SectionElement(elements: [emailElement], theme: LinkUI.appearance.asElementsTheme) + + private lazy var phoneNumberSection = SectionElement(elements: [phoneNumberElement], theme: LinkUI.appearance.asElementsTheme) + + private lazy var nameSection = SectionElement(elements: [nameElement], theme: LinkUI.appearance.asElementsTheme) + + private lazy var legalTermsView: LinkLegalTermsView = { + let legalTermsView = LinkLegalTermsView(textAlignment: .center) + legalTermsView.tintColor = .linkBrandDark + legalTermsView.delegate = self + return legalTermsView + }() + + private lazy var errorLabel: UILabel = { + let label = ElementsUI.makeErrorLabel(theme: LinkUI.appearance.asElementsTheme) + label.isHidden = true + return label + }() + + private lazy var signUpButton: Button = { + let button = Button( + configuration: .linkPrimary(), + title: STPLocalizedString( + "Join Link", + "Title for a button that when tapped creates a Link account for the user." + ) + ) + button.addTarget(self, action: #selector(didTapSignUpButton(_:)), for: .touchUpInside) + button.adjustsFontForContentSizeCategory = true + button.isEnabled = false + return button + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + subtitleLabel, + emailSection.view, + phoneNumberSection.view, + nameSection.view, + legalTermsView, + errorLabel, + signUpButton, + ]) + + stackView.axis = .vertical + stackView.spacing = LinkUI.contentSpacing + stackView.setCustomSpacing(LinkUI.smallContentSpacing, after: titleLabel) + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: subtitleLabel) + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: legalTermsView) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = LinkUI.contentMargins + + return stackView + }() + + init( + linkAccount: PaymentSheetLinkAccount?, + context: Context + ) { + self.viewModel = SignUpViewModel( + configuration: context.configuration, + accountService: LinkAccountService(apiClient: context.configuration.apiClient), + linkAccount: linkAccount, + country: context.elementsSession.countryCode + ) + super.init(context: context) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let scrollView = LinkKeyboardAvoidingScrollView(contentView: stackView) + #if !os(visionOS) + scrollView.keyboardDismissMode = .interactive + #endif + + contentView.addAndPinSubview(scrollView) + + setupBindings() + updateUI() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + STPAnalyticsClient.sharedClient.logLinkSignupFlowPresented() + } + + private func setupBindings() { + // Logic for determining the default phone number currently lives + // in the UI layer. In the absence of two-way data binding, we will + // need to sync up the view model with the view here. + viewModel.phoneNumber = phoneNumberElement.phoneNumber + + viewModel.delegate = self + emailElement.delegate = self + phoneNumberElement.delegate = self + nameElement.delegate = self + } + + private func updateUI(animated: Bool = false) { + if viewModel.isLookingUpLinkAccount { + emailElement.startAnimating() + } else { + emailElement.stopAnimating() + } + + // Phone number + stackView.toggleArrangedSubview( + phoneNumberSection.view, + shouldShow: viewModel.shouldShowPhoneNumberField, + animated: animated + ) + + // Name + stackView.toggleArrangedSubview( + nameSection.view, + shouldShow: viewModel.shouldShowNameField, + animated: animated + ) + + // Legal terms + stackView.toggleArrangedSubview( + legalTermsView, + shouldShow: viewModel.shouldShowLegalTerms, + animated: animated + ) + + // Error message + errorLabel.text = viewModel.errorMessage + stackView.toggleArrangedSubview( + errorLabel, + shouldShow: viewModel.errorMessage != nil, + animated: animated + ) + + // Signup button + stackView.toggleArrangedSubview( + signUpButton, + shouldShow: viewModel.shouldShowSignUpButton, + animated: animated + ) + + signUpButton.isEnabled = viewModel.shouldEnableSignUpButton + } + + @objc + func didTapSignUpButton(_ sender: Button) { + signUpButton.isLoading = true + + viewModel.signUp { [weak self] result in + switch result { + case .success(let account): + self?.coordinator?.accountUpdated(account) + STPAnalyticsClient.sharedClient.logLinkSignupComplete() + case .failure: break +// TODO(link): Fix signup failure logging +// STPAnalyticsClient.sharedClient.logLinkSignupFailure() + } + + self?.signUpButton.isLoading = false + } + } + + } + +} + +extension PayWithLinkViewController.SignUpViewController: PayWithLinkSignUpViewModelDelegate { + + func viewModelDidChange(_ viewModel: PayWithLinkViewController.SignUpViewModel) { + updateUI(animated: true) + } + + func viewModel( + _ viewModel: PayWithLinkViewController.SignUpViewModel, + didLookupAccount linkAccount: PaymentSheetLinkAccount? + ) { + if let linkAccount = linkAccount { + coordinator?.accountUpdated(linkAccount) + + if !linkAccount.isRegistered { + STPAnalyticsClient.sharedClient.logLinkSignupStart() + } + } + } + +} + +extension PayWithLinkViewController.SignUpViewController: ElementDelegate { + + func didUpdate(element: Element) { + switch emailElement.validationState { + case .valid: + viewModel.emailAddress = emailElement.emailAddressString + case .invalid: + viewModel.emailAddress = nil + } + + viewModel.phoneNumber = phoneNumberElement.phoneNumber + + switch nameElement.validationState { + case .valid: + viewModel.legalName = nameElement.text + case .invalid: + viewModel.legalName = nil + } + } + + func continueToNextField(element: Element) { + // No-op + } + +} + +extension PayWithLinkViewController.SignUpViewController: UITextViewDelegate { + +#if !os(visionOS) + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + if interaction == .invokeDefaultAction { + let safariVC = SFSafariViewController(url: URL) + present(safariVC, animated: true) + } + + return false + } +#endif + +} + +extension PayWithLinkViewController.SignUpViewController: LinkLegalTermsViewDelegate { + + func legalTermsView(_ legalTermsView: LinkLegalTermsView, didTapOnLinkWithURL url: URL) -> Bool { + let safariVC = SFSafariViewController(url: url) + #if !os(visionOS) + safariVC.dismissButtonStyle = .close + #endif + safariVC.modalPresentationStyle = .overFullScreen + present(safariVC, animated: true) + return true + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-SignUpViewModel.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-SignUpViewModel.swift new file mode 100644 index 00000000000..cbd560e6202 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-SignUpViewModel.swift @@ -0,0 +1,228 @@ +// +// PayWithLinkViewController-SignUpViewModel.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 5/16/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore + +protocol PayWithLinkSignUpViewModelDelegate: AnyObject { + func viewModelDidChange(_ viewModel: PayWithLinkViewController.SignUpViewModel) + func viewModel( + _ viewModel: PayWithLinkViewController.SignUpViewModel, + didLookupAccount linkAccount: PaymentSheetLinkAccount? + ) +} + +extension PayWithLinkViewController { + + final class SignUpViewModel { + weak var delegate: PayWithLinkSignUpViewModelDelegate? + + var emailAddress: String? { + didSet { + if emailAddress != oldValue { + onEmailUpdate() + } + } + } + + var legalName: String? { + didSet { + if legalName != oldValue { + notifyUpdate() + } + } + } + + var phoneNumber: PhoneNumber? { + didSet { + if phoneNumber != oldValue { + notifyUpdate() + } + } + } + + private(set) var linkAccount: PaymentSheetLinkAccount? { + didSet { + if linkAccount !== oldValue { + notifyUpdate() + } + } + } + + private(set) var errorMessage: String? { + didSet { + if errorMessage != oldValue { + notifyUpdate() + } + } + } + + private(set) var isLookingUpLinkAccount: Bool = false { + didSet { + if isLookingUpLinkAccount != oldValue { + notifyUpdate() + } + } + } + + var requiresNameCollection: Bool { + return country != "US" + } + + var legalNameProvided: Bool { + guard let legalName = legalName else { + return false + } + + return !legalName.isBlank + } + + var shouldShowPhoneNumberField: Bool { + guard let linkAccount = linkAccount else { + return false + } + + return !linkAccount.isRegistered + } + + var shouldShowNameField: Bool { + guard let linkAccount = linkAccount else { + return false + } + + return !linkAccount.isRegistered && requiresNameCollection + } + + var shouldShowLegalTerms: Bool { + return shouldShowPhoneNumberField + } + + var shouldShowSignUpButton: Bool { + return shouldShowPhoneNumberField + } + + var shouldEnableSignUpButton: Bool { + guard let linkAccount = linkAccount, + let phoneNumber = phoneNumber + else { + return false + } + + if linkAccount.isRegistered || !phoneNumber.isComplete { + return false + } + + if requiresNameCollection && !legalNameProvided { + return false + } + + return true + } + + // MARK: Private properties + + private let accountService: LinkAccountServiceProtocol + + private let accountLookupDebouncer = OperationDebouncer(debounceTime: LinkUI.accountLookupDebounceTime) + + private let configuration: PaymentSheet.Configuration + + private let country: String? + + // MARK: Initializer + + init( + configuration: PaymentSheet.Configuration, + accountService: LinkAccountServiceProtocol, + linkAccount: PaymentSheetLinkAccount?, + country: String? + ) { + self.configuration = configuration + self.accountService = accountService + self.linkAccount = linkAccount + self.emailAddress = linkAccount?.email + self.legalName = configuration.defaultBillingDetails.name + self.country = country + } + + // MARK: Methods + + func signUp(completion: @escaping (Result) -> Void) { + guard let linkAccount = linkAccount, + let phoneNumber = phoneNumber else { + assertionFailure("`signUp()` called without a link account or phone number") + return + } + + linkAccount.signUp( + with: phoneNumber, + legalName: requiresNameCollection ? legalName : nil, +// TODO(link): Was .button, add new consent action + consentAction: .checkbox_v0 + ) { [weak self] result in + switch result { + case .success: + completion(.success(linkAccount)) + case .failure(let error): + self?.errorMessage = error.nonGenericDescription + completion(.failure(error)) + } + } + } + + } + +} + +private extension PayWithLinkViewController.SignUpViewModel { + + func onEmailUpdate() { + linkAccount = nil + errorMessage = nil + + guard let emailAddress = emailAddress else { + accountLookupDebouncer.cancel() + isLookingUpLinkAccount = false + return + } + + accountLookupDebouncer.enqueue { [weak self] in + self?.isLookingUpLinkAccount = true + + self?.accountService.lookupAccount(withEmail: emailAddress) { result in + guard let self = self else { return } + + // Check the requested email address against the current one. Handle + // email address changes while a lookup is in-flight. + guard emailAddress == self.emailAddress else { + // The email used for this lookup does not match the current address, so we ignore it + return + } + + self.isLookingUpLinkAccount = false + + switch result { + case .success(let account): + self.linkAccount = account + self.delegate?.viewModel(self, didLookupAccount: account) + case .failure(let error): + self.linkAccount = nil + self.errorMessage = error.nonGenericDescription + } + } + } + } + + func notifyUpdate() { + delegate?.viewModelDidChange(self) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift new file mode 100644 index 00000000000..9fcc793f182 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift @@ -0,0 +1,193 @@ +// +// PayWithLinkViewController-UpdatePaymentViewController.swift +// StripePaymentSheet +// +// Created by Nick Porter on 1/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore + +protocol UpdatePaymentViewControllerDelegate: AnyObject { + func didUpdate(paymentMethod: ConsumerPaymentDetails) +} + +extension PayWithLinkViewController { + + /// For internal SDK use only + @objc(STP_Internal_UpdatePaymentViewController) + final class UpdatePaymentViewController: BaseViewController { + weak var delegate: UpdatePaymentViewControllerDelegate? + let linkAccount: PaymentSheetLinkAccount + let intent: Intent + var configuration: PaymentSheet.Configuration + let paymentMethod: ConsumerPaymentDetails + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .title) + label.textColor = .linkPrimaryText + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + label.textAlignment = .center + label.text = String.Localized.update_card + return label + }() + + private let thisIsYourDefaultLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .bodyEmphasized) + label.textColor = .linkSecondaryText + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 0 + label.textAlignment = .center + label.text = STPLocalizedString( + "This is your default", + "Text of a label indicating that a payment method is the default." + ) + return label + }() + + private lazy var updateButton: ConfirmButton = .makeLinkButton( + callToAction: .custom(title: String.Localized.update_card) + ) { [weak self] in + self?.updateCard() + } + + private lazy var cancelButton: Button = { + let button = Button(configuration: .linkSecondary(), title: String.Localized.cancel) + button.addTarget(self, action: #selector(didSelectCancel), for: .touchUpInside) + button.adjustsFontForContentSizeCategory = true + return button + }() + + private lazy var errorLabel: UILabel = { + return ElementsUI.makeErrorLabel(theme: LinkUI.appearance.asElementsTheme) + }() + + private lazy var cardEditElement = LinkCardEditElement( + paymentMethod: paymentMethod, + configuration: configuration) + + init(linkAccount: PaymentSheetLinkAccount, context: Context, paymentMethod: ConsumerPaymentDetails) { + self.linkAccount = linkAccount + self.intent = context.intent + self.configuration = context.configuration + self.configuration.linkPaymentMethodsOnly = true + self.paymentMethod = paymentMethod + super.init(context: context) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.cardEditElement.delegate = self + view.backgroundColor = .linkBackground + view.directionalLayoutMargins = LinkUI.contentMargins + errorLabel.isHidden = true + + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + cardEditElement.view, + errorLabel, + thisIsYourDefaultLabel, + updateButton, + cancelButton, + ]) + + stackView.axis = .vertical + stackView.spacing = LinkUI.contentSpacing + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: titleLabel) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = LinkUI.contentMargins + + let scrollView = LinkKeyboardAvoidingScrollView(contentView: stackView) + #if !os(visionOS) + scrollView.keyboardDismissMode = .interactive + #endif + + contentView.addAndPinSubview(scrollView) + + if !paymentMethod.isDefault { + thisIsYourDefaultLabel.isHidden = true + stackView.setCustomSpacing(LinkUI.largeContentSpacing, after: cardEditElement.view) + } else { + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: thisIsYourDefaultLabel) + } + + updateButton.update(state: .disabled) + } + + func updateCard() { + updateErrorLabel(for: nil) + + guard let params = cardEditElement.params else { + assertionFailure("Params are expected to be not `nil` when `updateCard()` is called.") + return + } + + cardEditElement.view.endEditing(true) + cardEditElement.view.isUserInteractionEnabled = false + updateButton.update(state: .processing) + + // When updating a card that is not the default and you send isDefault=false to the server you get + // "Can't unset payment details when it's not the default", so send nil instead of false + let updateParams = UpdatePaymentDetailsParams( + isDefault: params.setAsDefault ? true : nil, + details: .card(expiryDate: params.expiryDate, billingDetails: params.billingDetails) + ) + + linkAccount.updatePaymentDetails(id: paymentMethod.stripeID, updateParams: updateParams) { [weak self] result in + switch result { + case .success(let updatedPaymentDetails): + // Updates to CVC only get applied when the intent is confirmed so we manually add them here + // instead of including in the /update API call + if case .card(let card) = updatedPaymentDetails.details { + card.cvc = params.cvc + } + + self?.updateButton.update(state: .succeeded, style: nil, callToAction: nil, animated: true) { + self?.delegate?.didUpdate(paymentMethod: updatedPaymentDetails) + self?.navigationController?.popViewController(animated: true) + } + + case .failure(let error): + self?.updateErrorLabel(for: error) + self?.cardEditElement.view.isUserInteractionEnabled = true + self?.updateButton.update(state: .enabled) + } + } + } + + @objc func didSelectCancel() { + self.navigationController?.popViewController(animated: true) + } + + func updateErrorLabel(for error: Error?) { + errorLabel.text = error?.nonGenericDescription + errorLabel.setHiddenIfNecessary(error == nil) + } + + } + +} + +extension PayWithLinkViewController.UpdatePaymentViewController: ElementDelegate { + + func didUpdate(element: Element) { + updateErrorLabel(for: nil) + updateButton.update(state: cardEditElement.validationState.isValid ? .enabled : .disabled) + } + + func continueToNextField(element: Element) { + updateButton.update(state: cardEditElement.validationState.isValid ? .enabled : .disabled) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-VerifyAccountViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-VerifyAccountViewController.swift new file mode 100644 index 00000000000..901b6612ea0 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-VerifyAccountViewController.swift @@ -0,0 +1,76 @@ +// +// PayWithLinkViewController-VerifyAccountViewController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 1/10/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +extension PayWithLinkViewController { + + final class VerifyAccountViewController: BaseViewController { + + private let linkAccount: PaymentSheetLinkAccount + + private lazy var verificationVC: LinkVerificationViewController = { + let vc = LinkVerificationViewController(mode: .embedded, linkAccount: linkAccount) + vc.delegate = self + vc.view.backgroundColor = .clear + return vc + }() + + init(linkAccount: PaymentSheetLinkAccount, context: Context) { + self.linkAccount = linkAccount + super.init(context: context) + + addChild(verificationVC) + contentView.addAndPinSubview(verificationVC.view, insets: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func onCloseButtonTapped(_ sender: UIButton) { + super.onCloseButtonTapped(sender) + STPAnalyticsClient.sharedClient.logLink2FACancel() + } + } + +} + +extension PayWithLinkViewController.VerifyAccountViewController: LinkVerificationViewControllerDelegate { + + func verificationController( + _ controller: LinkVerificationViewController, + didFinishWithResult result: LinkVerificationViewController.VerificationResult + ) { + switch result { + case .completed: + coordinator?.accountUpdated(linkAccount) + case .canceled: + coordinator?.logout(cancel: false) + case .failed(let error): + let alertController = UIAlertController( + title: String.Localized.error, + message: error.nonGenericDescription, + preferredStyle: .alert + ) + + alertController.addAction(UIAlertAction( + title: String.Localized.ok, + style: .default, + handler: { [weak self] _ in + self?.coordinator?.logout(cancel: false) + } + )) + + navigationController?.present(alertController, animated: true) + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewController.swift new file mode 100644 index 00000000000..2da3517054e --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewController.swift @@ -0,0 +1,559 @@ +// +// PayWithLinkViewController-WalletViewController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 10/27/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import PassKit +import SafariServices +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore + +extension PayWithLinkViewController { + + final class WalletViewController: BaseViewController { + struct Constants { + static let applePayButtonHeight: CGFloat = 48 + } + + let linkAccount: PaymentSheetLinkAccount + + let viewModel: WalletViewModel + + private lazy var paymentPicker: LinkPaymentMethodPicker = { + let paymentPicker = LinkPaymentMethodPicker() + paymentPicker.delegate = self + paymentPicker.dataSource = self + paymentPicker.supportedPaymentMethodTypes = viewModel.supportedPaymentMethodTypes + paymentPicker.selectedIndex = viewModel.selectedPaymentMethodIndex + return paymentPicker + }() + + private lazy var instantDebitMandateView = LinkInstantDebitMandateView(delegate: self) + + private lazy var confirmButton = ConfirmButton.makeLinkButton( + callToAction: viewModel.confirmButtonCallToAction, + compact: viewModel.shouldUseCompactConfirmButton + ) { [weak self] in + self?.confirm() + } + + private lazy var cancelButton: Button = { + let button = Button( + configuration: viewModel.cancelButtonConfiguration, + title: String.Localized.pay_another_way + ) + button.addTarget(self, action: #selector(cancelButtonTapped(_:)), for: .touchUpInside) + return button + }() + + private lazy var separator = SeparatorLabel(text: String.Localized.or) + + private lazy var applePayButton: PKPaymentButton = { + let button = PKPaymentButton(paymentButtonType: .plain, paymentButtonStyle: .compatibleAutomatic) + button.addTarget(self, action: #selector(applePayButtonTapped(_:)), for: .touchUpInside) + button.cornerRadius = LinkUI.cornerRadius + + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.applePayButtonHeight) + ]) + + return button + }() + + private lazy var cvcElement: TextFieldElement = { + let configuration = TextFieldElement.CVCConfiguration(cardBrandProvider: { + [weak self] in + return self?.viewModel.cardBrand ?? .unknown + }) + + return TextFieldElement(configuration: configuration, theme: LinkUI.appearance.asElementsTheme) + }() + + private lazy var expiryDateElement: TextFieldElement = { + let configuration = TextFieldElement.ExpiryDateConfiguration() + return TextFieldElement(configuration: configuration, theme: LinkUI.appearance.asElementsTheme) + }() + + private lazy var expiredCardNoticeView: LinkNoticeView = { + let noticeView = LinkNoticeView(type: .error) + noticeView.text = viewModel.noticeText + return noticeView + }() + + private lazy var cardDetailsRecollectionSection: SectionElement = { + let sectionElement = SectionElement( + elements: [ + SectionElement.MultiElementRow([expiryDateElement, cvcElement], theme: LinkUI.appearance.asElementsTheme) + ], theme: LinkUI.appearance.asElementsTheme + ) + sectionElement.delegate = self + return sectionElement + }() + + private lazy var paymentPickerContainerView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + paymentPicker, + instantDebitMandateView, + expiredCardNoticeView, + ]) + stackView.axis = .vertical + stackView.spacing = LinkUI.contentSpacing + return stackView + }() + + private lazy var errorLabel: UILabel = { + let label = ElementsUI.makeErrorLabel(theme: LinkUI.appearance.asElementsTheme) + label.textAlignment = .center + label.isHidden = true + return label + }() + + private lazy var containerView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + paymentPickerContainerView, + cardDetailsRecollectionSection.view, + errorLabel, + confirmButton, + ]) + stackView.axis = .vertical + stackView.spacing = LinkUI.contentSpacing + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: paymentPickerContainerView) + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: cardDetailsRecollectionSection.view) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = preferredContentMargins + return stackView + }() + + #if !os(visionOS) + private let feedbackGenerator = UINotificationFeedbackGenerator() + #endif + + init( + linkAccount: PaymentSheetLinkAccount, + context: Context, + paymentMethods: [ConsumerPaymentDetails] + ) { + self.linkAccount = linkAccount + self.viewModel = WalletViewModel(linkAccount: linkAccount, context: context, paymentMethods: paymentMethods) + super.init(context: context) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + updateUI(animated: false) + viewModel.delegate = self + } + + func setupUI() { + if viewModel.shouldShowApplePayButton { + containerView.addArrangedSubview(separator) + containerView.addArrangedSubview(applePayButton) + } + + containerView.addArrangedSubview(cancelButton) + + let scrollView = LinkKeyboardAvoidingScrollView(contentView: containerView) + #if !os(visionOS) + scrollView.keyboardDismissMode = .interactive + #endif + + contentView.addAndPinSubview(scrollView) + + // If the initially selected payment method is not supported, we should automatically + // expand the payment picker to hint the user to pick another payment method. + if !viewModel.selectedPaymentMethodIsSupported { + paymentPicker.setExpanded(true, animated: false) + } + } + + func updateUI(animated: Bool) { + if !viewModel.shouldRecollectCardCVC && !viewModel.shouldRecollectCardExpiryDate { + cardDetailsRecollectionSection.view.endEditing(true) + } + + paymentPickerContainerView.toggleArrangedSubview( + instantDebitMandateView, + shouldShow: viewModel.shouldShowInstantDebitMandate, + animated: animated + ) + + expiredCardNoticeView.text = viewModel.noticeText + containerView.toggleArrangedSubview( + expiredCardNoticeView, + shouldShow: viewModel.shouldShowNotice, + animated: animated + ) + + containerView.toggleArrangedSubview( + cardDetailsRecollectionSection.view, + shouldShow: viewModel.shouldShowRecollectionSection, + animated: animated + ) + + UIView.performWithoutAnimation { + expiryDateElement.view.setHiddenIfNecessary(!viewModel.shouldRecollectCardExpiryDate) + cvcElement.view.setHiddenIfNecessary(!viewModel.shouldRecollectCardCVC) + cardDetailsRecollectionSection.view.layoutIfNeeded() + } + + confirmButton.update( + state: viewModel.confirmButtonStatus, + callToAction: viewModel.confirmButtonCallToAction + ) + } + + func updateErrorLabel(for error: Error?) { + errorLabel.text = error?.nonGenericDescription + containerView.toggleArrangedSubview(errorLabel, shouldShow: error != nil, animated: true) + } + + func confirm() { + guard let paymentDetails = viewModel.selectedPaymentMethod else { + assertionFailure("`confirm()` called without a selected payment method") + return + } + + let confirmWithPaymentDetails: (ConsumerPaymentDetails) -> Void = { [self] paymentDetails in + if viewModel.shouldRecollectCardCVC { + if case let .card(card) = paymentDetails.details { + card.cvc = viewModel.cvc + } + } + + confirm(for: context.intent, with: paymentDetails) + } + + if viewModel.shouldRecollectCardExpiryDate { + confirmButton.update(state: .processing) + + viewModel.updateExpiryDate { [weak self] result in + switch result { + case .success(let paymentDetails): + confirmWithPaymentDetails(paymentDetails) + case .failure(let error): + let alertController = UIAlertController( + title: nil, + message: error.localizedDescription, + preferredStyle: .alert + ) + alertController.addAction(.init(title: String.Localized.ok, style: .default)) + self?.present(alertController, animated: true) + self?.confirmButton.update(state: .enabled) + } + } + } else { + confirmWithPaymentDetails(paymentDetails) + } + } + + func confirm(for intent: Intent, with paymentDetails: ConsumerPaymentDetails) { + view.endEditing(true) + + #if !os(visionOS) + feedbackGenerator.prepare() + #endif + updateErrorLabel(for: nil) + confirmButton.update(state: .processing) + + coordinator?.confirm(with: linkAccount, paymentDetails: paymentDetails) { [weak self] result in + switch result { + case .completed: + #if !os(visionOS) + self?.feedbackGenerator.notificationOccurred(.success) + #endif + self?.confirmButton.update(state: .succeeded, animated: true) { + self?.coordinator?.finish(withResult: result) + } + case .canceled: + self?.confirmButton.update(state: .enabled) + case .failed(let error): + #if !os(visionOS) + self?.feedbackGenerator.notificationOccurred(.error) + #endif + self?.updateErrorLabel(for: error) + self?.confirmButton.update(state: .enabled) + } + } + } + + @objc + func applePayButtonTapped(_ sender: PKPaymentButton) { + coordinator?.confirmWithApplePay() + } + + @objc + func cancelButtonTapped(_ sender: Button) { + coordinator?.cancel() + } + + } + +} + +private extension PayWithLinkViewController.WalletViewController { + + func removePaymentMethod(at index: Int) { + let paymentMethod = viewModel.paymentMethods[index] + + let alertTitle: String = { + switch paymentMethod.details { + case .card: + return STPLocalizedString( + "Are you sure you want to remove this card?", + "Title of confirmation prompt when removing a saved card." + ) + case .bankAccount: + return STPLocalizedString( + "Are you sure you want to remove this linked account?", + "Title of confirmation prompt when removing a linked bank account." + ) + case .unparsable: + return "" + } + }() + + let alertController = UIAlertController( + title: alertTitle, + message: nil, + preferredStyle: .alert + ) + + alertController.addAction(UIAlertAction( + title: String.Localized.cancel, + style: .cancel + )) + + alertController.addAction(UIAlertAction( + title: String.Localized.remove, + style: .destructive, + handler: { _ in + self.paymentPicker.showLoader(at: index) + + self.viewModel.deletePaymentMethod(at: index) { result in + switch result { + case .success: + self.paymentPicker.removePaymentMethod(at: index, animated: true) + case .failure: + break + } + + self.paymentPicker.hideLoader(at: index) + } + } + )) + + present(alertController, animated: true) + } + + func updatePaymentMethod(at index: Int) { + let paymentMethod = viewModel.paymentMethods[index] + let updatePaymentMethodVC = PayWithLinkViewController.UpdatePaymentViewController( + linkAccount: linkAccount, + context: context, + paymentMethod: paymentMethod + ) + updatePaymentMethodVC.delegate = self + + navigationController?.pushViewController(updatePaymentMethodVC, animated: true) + } + +} + +// MARK: - ElementDelegate + +extension PayWithLinkViewController.WalletViewController: ElementDelegate { + + func didUpdate(element: Element) { + switch expiryDateElement.validationState { + case .valid: + viewModel.expiryDate = CardExpiryDate(expiryDateElement.text) + case .invalid: + viewModel.expiryDate = nil + } + + switch cvcElement.validationState { + case .valid: + viewModel.cvc = cvcElement.text + case .invalid: + viewModel.cvc = nil + } + } + + func continueToNextField(element: Element) { + } + +} + +// MARK: - PayWithLinkWalletViewModelDelegate + +extension PayWithLinkViewController.WalletViewController: PayWithLinkWalletViewModelDelegate { + + func viewModelDidChange(_ viewModel: PayWithLinkViewController.WalletViewModel) { + updateUI(animated: true) + } + +} + +// MARK: - LinkPaymentMethodPickerDataSource + +extension PayWithLinkViewController.WalletViewController: LinkPaymentMethodPickerDataSource { + + func numberOfPaymentMethods(in picker: LinkPaymentMethodPicker) -> Int { + return viewModel.paymentMethods.count + } + + func paymentPicker(_ picker: LinkPaymentMethodPicker, paymentMethodAt index: Int) -> ConsumerPaymentDetails { + return viewModel.paymentMethods[index] + } + +} + +// MARK: - LinkPaymentMethodPickerDelegate + +extension PayWithLinkViewController.WalletViewController: LinkPaymentMethodPickerDelegate { + + func paymentMethodPickerDidChange(_ pickerView: LinkPaymentMethodPicker) { + viewModel.selectedPaymentMethodIndex = pickerView.selectedIndex + if viewModel.selectedPaymentMethodIsSupported { + pickerView.setExpanded(false, animated: true) + } + } + + func paymentMethodPicker( + _ pickerView: LinkPaymentMethodPicker, + showMenuForItemAt index: Int, + sourceRect: CGRect + ) { + let paymentMethod = viewModel.paymentMethods[index] + + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alertController.popoverPresentationController?.sourceView = pickerView + alertController.popoverPresentationController?.sourceRect = sourceRect + + if !paymentMethod.isDefault { + alertController.addAction(UIAlertAction( + title: STPLocalizedString( + "Set as default", + "Label for a button or menu item that sets a payment method as default when tapped." + ), + style: .default, + handler: { [self] _ in + paymentPicker.showLoader(at: index) + viewModel.setDefaultPaymentMethod(at: index) { [weak self] _ in + self?.paymentPicker.hideLoader(at: index) + self?.paymentPicker.reloadData() + } + } + )) + } + + if case ConsumerPaymentDetails.Details.card(_) = paymentMethod.details { + alertController.addAction(UIAlertAction( + title: String.Localized.update_card, + style: .default, + handler: { _ in + self.updatePaymentMethod(at: index) + } + )) + } + + let removeTitle: String = { + switch paymentMethod.details { + case .card: + return String.Localized.remove_card + case .bankAccount: + return STPLocalizedString( + "Remove linked account", + "Title for a button that when tapped removes a linked bank account." + ) + case .unparsable: + return "" + } + }() + alertController.addAction(UIAlertAction( + title: removeTitle, + style: .destructive, + handler: { _ in + self.removePaymentMethod(at: index) + } + )) + + alertController.addAction(UIAlertAction( + title: String.Localized.cancel, + style: .cancel + )) + + present(alertController, animated: true) + } + + func paymentDetailsPickerDidTapOnAddPayment(_ pickerView: LinkPaymentMethodPicker) { + if context.elementsSession.onlySupportsLinkBank { + // If this business is bank-only, bypass the new payment method flow and go straight to connections + confirmButton.update(state: .processing) + pickerView.setAddPaymentMethodButtonEnabled(false) + coordinator?.startInstantDebits { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let paymentDetails): + self.didUpdate(paymentMethod: paymentDetails) + case .failure(let error): + switch error { + case InstantDebitsOnlyAuthenticationSessionManager.Error.canceled: + break + default: + self.updateErrorLabel(for: error) + } + } + self.paymentPicker.setAddPaymentMethodButtonEnabled(true) + self.updateUI(animated: false) + } + } else { + let newPaymentVC = PayWithLinkViewController.NewPaymentViewController( + linkAccount: linkAccount, + context: context, + isAddingFirstPaymentMethod: false + ) + + navigationController?.pushViewController(newPaymentVC, animated: true) + } + } + +} + +// MARK: - LinkInstantDebitMandateViewDelegate + +extension PayWithLinkViewController.WalletViewController: LinkInstantDebitMandateViewDelegate { + + func instantDebitMandateView(_ mandateView: LinkInstantDebitMandateView, didTapOnLinkWithURL url: URL) { + let safariVC = SFSafariViewController(url: url) + #if !os(visionOS) + safariVC.dismissButtonStyle = .close + #endif + safariVC.modalPresentationStyle = .overFullScreen + present(safariVC, animated: true) + } + +} + +// MARK: - UpdatePaymentViewControllerDelegate + +extension PayWithLinkViewController.WalletViewController: UpdatePaymentViewControllerDelegate { + + func didUpdate(paymentMethod: ConsumerPaymentDetails) { + if let index = viewModel.updatePaymentMethod(paymentMethod) { + self.paymentPicker.selectedIndex = index + self.paymentPicker.reloadData() + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewModel.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewModel.swift new file mode 100644 index 00000000000..0242f15cd6c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewModel.swift @@ -0,0 +1,291 @@ +// +// PayWithLinkViewController-WalletViewModel.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 3/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore + +protocol PayWithLinkWalletViewModelDelegate: AnyObject { + func viewModelDidChange(_ viewModel: PayWithLinkViewController.WalletViewModel) +} + +extension PayWithLinkViewController { + + final class WalletViewModel { + let context: Context + let linkAccount: PaymentSheetLinkAccount + private(set) var paymentMethods: [ConsumerPaymentDetails] + + weak var delegate: PayWithLinkWalletViewModelDelegate? + + /// Index of currently selected payment method. + var selectedPaymentMethodIndex: Int { + didSet { + if oldValue != selectedPaymentMethodIndex { + delegate?.viewModelDidChange(self) + } + } + } + + var supportedPaymentMethodTypes: Set { + return linkAccount.supportedPaymentDetailsTypes(for: context.elementsSession) + } + + var cvc: String? { + didSet { + if oldValue != cvc { + delegate?.viewModelDidChange(self) + } + } + } + + var expiryDate: CardExpiryDate? { + didSet { + if oldValue != expiryDate { + delegate?.viewModelDidChange(self) + } + } + } + + /// Currently selected payment method. + var selectedPaymentMethod: ConsumerPaymentDetails? { + guard paymentMethods.indices.contains(selectedPaymentMethodIndex) else { + return nil + } + + return paymentMethods[selectedPaymentMethodIndex] + } + + /// Whether or not the view should show the instant debit mandate text. + var shouldShowInstantDebitMandate: Bool { + switch selectedPaymentMethod?.details { + case .bankAccount: + // Instant debit mandate should be shown when paying with bank account. + return true + default: + return false + } + } + + var noticeText: String? { + if shouldRecollectCardExpiryDate { + return STPLocalizedString( + "This card has expired. Update your card info or choose a different payment method.", + "A text notice shown when the user selects an expired card." + ) + } + + if shouldRecollectCardCVC { + return STPLocalizedString( + "For security, please re-enter your card’s security code.", + """ + A text notice shown when the user selects a card that requires + re-entering the security code (CVV/CVC). + """ + ) + } + + return nil + } + + var shouldShowNotice: Bool { + return noticeText != nil + } + + var shouldShowRecollectionSection: Bool { + return ( + shouldRecollectCardCVC || + shouldRecollectCardExpiryDate + ) + } + + var shouldShowApplePayButton: Bool { + return ( + context.shouldOfferApplePay && + context.configuration.isApplePayEnabled + ) + } + + var shouldUseCompactConfirmButton: Bool { + // We should use a compact confirm button whenever we display the Apple Pay button. + return shouldShowApplePayButton + } + + var cancelButtonConfiguration: Button.Configuration { + return shouldShowApplePayButton ? .linkPlain() : .linkSecondary() + } + + /// Whether or not we must re-collect the card CVC. + var shouldRecollectCardCVC: Bool { + switch selectedPaymentMethod?.details { + case .card(let card): + return card.shouldRecollectCardCVC || card.hasExpired + default: + // Only cards have CVC. + return false + } + } + + var shouldRecollectCardExpiryDate: Bool { + switch selectedPaymentMethod?.details { + case .card(let card): + return card.hasExpired + case .bankAccount, .unparsable, .none: + // Only cards have expiry date. + return false + } + } + + /// CTA + var confirmButtonCallToAction: ConfirmButton.CallToActionType { + context.callToAction + } + + var confirmButtonStatus: ConfirmButton.Status { + if selectedPaymentMethod == nil { + return .disabled + } + + if !selectedPaymentMethodIsSupported { + // Selected payment method not supported + return .disabled + } + + if shouldRecollectCardCVC && cvc == nil { + return .disabled + } + + if shouldRecollectCardExpiryDate && expiryDate == nil { + return .disabled + } + + return .enabled + } + + var cardBrand: STPCardBrand? { + switch selectedPaymentMethod?.details { + case .card(let card): + return card.stpBrand + default: + return nil + } + } + + var selectedPaymentMethodIsSupported: Bool { + guard let selectedPaymentMethod = selectedPaymentMethod else { + return false + } + + return supportedPaymentMethodTypes.contains(selectedPaymentMethod.type) + } + + init( + linkAccount: PaymentSheetLinkAccount, + context: Context, + paymentMethods: [ConsumerPaymentDetails] + ) { + self.linkAccount = linkAccount + self.context = context + self.paymentMethods = paymentMethods + self.selectedPaymentMethodIndex = Self.determineInitiallySelectedPaymentMethod( + context: context, + paymentMethods: paymentMethods + ) + } + + func deletePaymentMethod(at index: Int, completion: @escaping (Result) -> Void) { + let paymentMethod = paymentMethods[index] + + linkAccount.deletePaymentDetails(id: paymentMethod.stripeID) { [self] result in + switch result { + case .success: + paymentMethods.remove(at: index) + delegate?.viewModelDidChange(self) + case .failure: + break + } + + completion(result) + } + } + + func setDefaultPaymentMethod( + at index: Int, + completion: @escaping (Result) -> Void + ) { + let paymentMethod = paymentMethods[index] + + linkAccount.updatePaymentDetails( + id: paymentMethod.stripeID, + updateParams: UpdatePaymentDetailsParams(isDefault: true, details: nil) + ) { [self] result in + if case let .success(updatedPaymentDetails) = result { + paymentMethods.forEach({ $0.isDefault = false }) + paymentMethods[index] = updatedPaymentDetails + } + + completion(result) + } + } + + func updatePaymentMethod(_ paymentMethod: ConsumerPaymentDetails) -> Int? { + guard let index = paymentMethods.firstIndex(where: { $0.stripeID == paymentMethod.stripeID }) else { + return nil + } + + if paymentMethod.isDefault { + paymentMethods.forEach({ $0.isDefault = false }) + } + + paymentMethods[index] = paymentMethod + + delegate?.viewModelDidChange(self) + + return index + } + + func updateExpiryDate(completion: @escaping (Result) -> Void) { + guard + let id = selectedPaymentMethod?.stripeID, + let expiryDate = self.expiryDate + else { + assertionFailure("Called with no selected payment method or expiry date provided.") + return + } + + linkAccount.updatePaymentDetails( + id: id, + updateParams: UpdatePaymentDetailsParams(details: .card(expiryDate: expiryDate)), + completion: completion + ) + } + } + +} + +private extension PayWithLinkViewController.WalletViewModel { + + static func determineInitiallySelectedPaymentMethod( + context: PayWithLinkViewController.Context, + paymentMethods: [ConsumerPaymentDetails] + ) -> Int { + var indexOfLastAddedPaymentMethod: Int? { + guard let lastAddedID = context.lastAddedPaymentDetails?.stripeID else { + return nil + } + + return paymentMethods.firstIndex(where: { $0.stripeID == lastAddedID }) + } + + var indexOfDefaultPaymentMethod: Int? { + return paymentMethods.firstIndex(where: { $0.isDefault }) + } + + return indexOfLastAddedPaymentMethod ?? indexOfDefaultPaymentMethod ?? 0 + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift new file mode 100644 index 00000000000..449bfc90df4 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift @@ -0,0 +1,355 @@ +// +// PayWithLinkViewController.swift +// StripePaymentSheet +// +// Created by Cameron Sabol on 9/3/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore + +protocol PayWithLinkViewControllerDelegate: AnyObject { + + func payWithLinkViewControllerDidConfirm( + _ payWithLinkViewController: PayWithLinkViewController, + intent: Intent, + with paymentOption: PaymentOption, + completion: @escaping (PaymentSheetResult, STPAnalyticsClient.DeferredIntentConfirmationType?) -> Void + ) + + func payWithLinkViewControllerDidCancel(_ payWithLinkViewController: PayWithLinkViewController) + + func payWithLinkViewControllerDidFinish( + _ payWithLinkViewController: PayWithLinkViewController, + result: PaymentSheetResult + ) + +} + +protocol PayWithLinkCoordinating: AnyObject { + func confirm( + with linkAccount: PaymentSheetLinkAccount, + paymentDetails: ConsumerPaymentDetails, + completion: @escaping (PaymentSheetResult) -> Void + ) + func confirmWithApplePay() + func startInstantDebits(completion: @escaping (Result) -> Void) + func cancel() + func accountUpdated(_ linkAccount: PaymentSheetLinkAccount) + func finish(withResult result: PaymentSheetResult) + func logout(cancel: Bool) +} + +/// A view controller for paying with Link. +/// +/// Instantiate and present this controller when the user chooses to pay with Link. +/// For internal SDK use only +@objc(STP_Internal_PayWithLinkViewController) +final class PayWithLinkViewController: UINavigationController { + + enum LinkAccountError: Error { + case noLinkAccount + + var localizedDescription: String { + "No Link account is set" + } + } + + final class Context { + let intent: Intent + let elementsSession: STPElementsSession + let configuration: PaymentSheet.Configuration + let shouldOfferApplePay: Bool + let shouldFinishOnClose: Bool + let callToAction: ConfirmButton.CallToActionType + var lastAddedPaymentDetails: ConsumerPaymentDetails? + + /// Creates a new Context object. + /// - Parameters: + /// - intent: Intent. + /// - configuration: PaymentSheet configuration. + /// - shouldOfferApplePay: Whether or not to show Apple Pay as a payment option. + /// - shouldFinishOnClose: Whether or not Link should finish with `.canceled` result instead of returning to Payment Sheet when the close button is tapped. + /// - callToAction: A custom CTA to display on the confirm button. If `nil`, will display `intent`'s default CTA. + init( + intent: Intent, + elementsSession: STPElementsSession, + configuration: PaymentSheet.Configuration, + shouldOfferApplePay: Bool, + shouldFinishOnClose: Bool, + callToAction: ConfirmButton.CallToActionType? + ) { + self.intent = intent + self.elementsSession = elementsSession + self.configuration = configuration + self.shouldOfferApplePay = shouldOfferApplePay + self.shouldFinishOnClose = shouldFinishOnClose + self.callToAction = callToAction ?? intent.callToAction + } + } + + private var context: Context + private var accountContext: LinkAccountContext = .shared + + private var linkAccount: PaymentSheetLinkAccount? { + get { accountContext.account } + set { accountContext.account = newValue } + } + + weak var payWithLinkDelegate: PayWithLinkViewControllerDelegate? + + private var isShowingLoader: Bool { + guard let rootViewController = viewControllers.first else { + return false + } + + return rootViewController is LoaderViewController + } + + convenience init( + intent: Intent, + elementsSession: STPElementsSession, + configuration: PaymentSheet.Configuration, + shouldOfferApplePay: Bool = false, + shouldFinishOnClose: Bool = false, + callToAction: ConfirmButton.CallToActionType? = nil + ) { + self.init( + context: Context( + intent: intent, + elementsSession: elementsSession, + configuration: configuration, + shouldOfferApplePay: shouldOfferApplePay, + shouldFinishOnClose: shouldFinishOnClose, + callToAction: callToAction + ) + ) + } + + private init(context: Context) { + self.context = context + super.init(nibName: nil, bundle: nil) + + // Show loader + setRootViewController(LoaderViewController(context: context), animated: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.accessibilityIdentifier = "Stripe.Link.PayWithLinkViewController" + view.tintColor = .linkBrand + + // Hide the default navigation bar. + setNavigationBarHidden(true, animated: false) + + // Apply the preferred user interface style. + context.configuration.style.configure(self) + + updateSupportedPaymentMethods() + updateUI() + + // The internal delegate of the interactive pop gesture disables + // the gesture when the navigation bar is hidden. Use a custom delegate + // to restore the functionality. + interactivePopGestureRecognizer?.delegate = self + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + if let viewController = viewController as? BaseViewController { + viewController.coordinator = self + viewController.customNavigationBar.linkAccount = linkAccount + viewController.customNavigationBar.showBackButton = !viewControllers.isEmpty + } + + super.pushViewController(viewController, animated: animated) + } + + private func updateUI() { + guard let linkAccount = linkAccount else { + if !(rootViewController is SignUpViewController) { + setRootViewController( + SignUpViewController(linkAccount: nil, context: context) + ) + } + return + } + + switch linkAccount.sessionState { + case .requiresSignUp: + if !(rootViewController is SignUpViewController) { + setRootViewController( + SignUpViewController(linkAccount: linkAccount, context: context) + ) + } + case .requiresVerification: + setRootViewController(VerifyAccountViewController(linkAccount: linkAccount, context: context)) + case .verified: + loadAndPresentWallet() + } + } + +} + +extension PayWithLinkViewController: UIGestureRecognizerDelegate { + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return viewControllers.count > 1 + } + +} + +// MARK: - Utils + +private extension PayWithLinkViewController { + + func loadAndPresentWallet() { + let shouldAnimate = !(rootViewController is WalletViewController) + setRootViewController(LoaderViewController(context: context), animated: shouldAnimate) + + guard let linkAccount = linkAccount else { + assertionFailure(LinkAccountError.noLinkAccount.localizedDescription) + return + } + + linkAccount.listPaymentDetails { result in + switch result { + case .success(let paymentDetails): + if paymentDetails.isEmpty { + let addPaymentMethodVC = NewPaymentViewController( + linkAccount: linkAccount, + context: self.context, + isAddingFirstPaymentMethod: true + ) + + self.setRootViewController(addPaymentMethodVC) + } else { + let walletViewController = WalletViewController( + linkAccount: linkAccount, + context: self.context, + paymentMethods: paymentDetails + ) + + self.setRootViewController(walletViewController) + } + case .failure(let error): + self.payWithLinkDelegate?.payWithLinkViewControllerDidFinish( + self, result: PaymentSheetResult.failed(error: error) + ) + } + } + } + + func updateSupportedPaymentMethods() { + PaymentSheet.supportedLinkPaymentMethods = + linkAccount?.supportedPaymentMethodTypes(for: context.elementsSession) ?? [] + } + +} + +// MARK: - Navigation + +private extension PayWithLinkViewController { + + var rootViewController: UIViewController? { + return viewControllers.first + } + + func setRootViewController(_ viewController: UIViewController, animated: Bool = true) { + if let viewController = viewController as? BaseViewController { + viewController.coordinator = self + viewController.customNavigationBar.linkAccount = linkAccount + viewController.customNavigationBar.showBackButton = false + } + + setViewControllers([viewController], animated: isShowingLoader ? false : animated) + } + +} + +// MARK: - Coordinating + +extension PayWithLinkViewController: PayWithLinkCoordinating { + func startInstantDebits(completion: @escaping (Result) -> Void) { + // TODO(link): Not yet implemented. + } + + func confirm( + with linkAccount: PaymentSheetLinkAccount, + paymentDetails: ConsumerPaymentDetails, + completion: @escaping (PaymentSheetResult) -> Void + ) { + view.isUserInteractionEnabled = false + + payWithLinkDelegate?.payWithLinkViewControllerDidConfirm( + self, + intent: context.intent, + with: PaymentOption.link( + option: .withPaymentDetails(account: linkAccount, paymentDetails: paymentDetails) + ) + ) { [weak self] result, _ in +// TODO(link): Log confirmation type here + self?.view.isUserInteractionEnabled = true + completion(result) + } + } + + func confirmWithApplePay() { + payWithLinkDelegate?.payWithLinkViewControllerDidConfirm( + self, + intent: context.intent, + with: .applePay + ) { [weak self] result, _ in + // TODO(link): Log confirmation type here + switch result { + case .canceled: + // no-op -- we don't dismiss/finish for canceled Apple Pay interactions + break + case .completed, .failed: + self?.finish(withResult: result) + } + } + } + + func cancel() { + payWithLinkDelegate?.payWithLinkViewControllerDidCancel(self) + } + + func accountUpdated(_ linkAccount: PaymentSheetLinkAccount) { + self.linkAccount = linkAccount + updateSupportedPaymentMethods() + updateUI() + } + + func finish(withResult result: PaymentSheetResult) { + view.isUserInteractionEnabled = false + payWithLinkDelegate?.payWithLinkViewControllerDidFinish(self, result: result) + } + + func logout(cancel: Bool) { + linkAccount?.logout() + linkAccount = nil + + if cancel { + self.cancel() + } else { + updateUI() + } + } + +} + +extension PayWithLinkViewController: STPAuthenticationContext { + + func authenticationPresentingViewController() -> UIViewController { + return self + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/LinkCardEditElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/LinkCardEditElement.swift new file mode 100644 index 00000000000..61eea7e0a25 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/LinkCardEditElement.swift @@ -0,0 +1,225 @@ +// +// LinkCardEditElement.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 9/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +// TODO(ramont): Remove after migrating to modern bindings +fileprivate extension ConsumerPaymentDetails { + var cardDetails: Details.Card? { + switch details { + case .card(let details): + return details + case .bankAccount: + return nil + case .unparsable: + return nil + } + } +} + +final class LinkCardEditElement: Element { + let collectsUserInput: Bool = true + + struct Params { + let expiryDate: CardExpiryDate + let cvc: String + let billingDetails: STPPaymentMethodBillingDetails + let setAsDefault: Bool + } + + var view: UIView { + return formElement.view + } + + weak var delegate: ElementDelegate? + + var validationState: ElementValidationState { + return formElement.validationState + } + + let paymentMethod: ConsumerPaymentDetails + + let configuration: PaymentSheet.Configuration + + let theme: ElementsAppearance = LinkUI.appearance.asElementsTheme + + var params: Params? { + guard validationState.isValid, + let expiryDate = CardExpiryDate(expiryDateElement.text) else { + return nil + } + + // TODO(ramont): Replace `STPPaymentMethodBillingDetails` with a custom struct for Link. + // This matches the object that was returned by CardDetailsEditView, but won't work + // with `collectionMode: .all`, because extra fields won't match what expected by Link. + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = nameElement?.text + billingDetails.email = emailElement?.text + billingDetails.nonnil_address.country = billingAddressSection?.selectedCountryCode + billingDetails.nonnil_address.line1 = billingAddressSection?.line1?.text + billingDetails.nonnil_address.line2 = billingAddressSection?.line2?.text + billingDetails.nonnil_address.city = billingAddressSection?.city?.text + billingDetails.nonnil_address.state = billingAddressSection?.state?.rawData + billingDetails.nonnil_address.postalCode = billingAddressSection?.postalCode?.text + + return Params( + expiryDate: expiryDate, + cvc: cvcElement.text, + billingDetails: billingDetails, + setAsDefault: checkboxElement.checkboxButton.isSelected + ) + } + + private lazy var emailElement: TextFieldElement? = { + guard configuration.billingDetailsCollectionConfiguration.email == .always else { return nil } + + return TextFieldElement.makeEmail(defaultValue: nil, theme: theme) + }() + + private lazy var contactInformationSection: SectionElement? = { + guard let emailElement = emailElement else { return nil } + + return SectionElement( + title: STPLocalizedString("Contact information", "Title for the contact information section"), + elements: [emailElement], + theme: theme) + }() + + private lazy var nameElement: TextFieldElement? = { + guard configuration.billingDetailsCollectionConfiguration.name == .always else { return nil } + + return TextFieldElement.makeName( + label: STPLocalizedString("Name on card", "Label for name on card field"), + defaultValue: nil, + theme: theme) + }() + + private lazy var panElement: TextFieldElement = { + let panElement = TextFieldElement( + configuration: PANConfiguration(paymentMethod: paymentMethod), + theme: theme + ) + panElement.view.isUserInteractionEnabled = false + return panElement + }() + + private lazy var cvcElement = TextFieldElement( + configuration: TextFieldElement.CVCConfiguration( + cardBrandProvider: { [weak self] in + self?.paymentMethod.cardDetails?.stpBrand ?? .unknown + } + ), + theme: theme + ) + + private lazy var expiryDateElement = TextFieldElement( + configuration: TextFieldElement.ExpiryDateConfiguration(), + theme: theme + ) + + private lazy var checkboxElement = CheckboxElement( + theme: theme, + label: STPLocalizedString( + "Set as default payment method", + "Label of a checkbox that when checked makes a payment method as the default one." + ), + isSelectedByDefault: paymentMethod.isDefault + ) + + private lazy var formElement: FormElement = { + let formElement = FormElement( + elements: [ + contactInformationSection, + cardSection, + billingAddressSection, + checkboxElement, + ], + theme: theme + ) + formElement.delegate = self + return formElement + }() + + private lazy var cardSection: SectionElement = { + let allElements: [Element?] = [ + nameElement, + panElement, + SectionElement.MultiElementRow([expiryDateElement, cvcElement], theme: theme), + ] + let elements = allElements.compactMap { $0 } + + return SectionElement( + title: String.Localized.card_information, + elements: elements, + theme: theme + ) + }() + + private lazy var billingAddressSection: AddressSectionElement? = { + guard configuration.billingDetailsCollectionConfiguration.address != .never else { return nil } + + return AddressSectionElement( + title: String.Localized.billing_address, + collectionMode: configuration.billingDetailsCollectionConfiguration.address == .full + ? .all() + : .countryAndPostal(), + theme: theme + ) + }() + + init(paymentMethod: ConsumerPaymentDetails, configuration: PaymentSheet.Configuration) { + self.paymentMethod = paymentMethod + self.configuration = configuration + + if let expiryDate = paymentMethod.cardDetails?.expiryDate { + self.expiryDateElement.setText(expiryDate.displayString) + } + + self.checkboxElement.checkboxButton.isHidden = paymentMethod.isDefault + } + +} + +extension LinkCardEditElement: ElementDelegate { + + func didUpdate(element: Element) { + delegate?.didUpdate(element: self) + } + + func continueToNextField(element: Element) { + delegate?.continueToNextField(element: self) + } + +} + +private extension LinkCardEditElement { + + struct PANConfiguration: TextFieldElementConfiguration { + let paymentMethod: ConsumerPaymentDetails + + var label: String { + String.Localized.card_number + } + + var defaultValue: String? { + paymentMethod.cardDetails.map { "•••• \($0.last4)" } + } + + func accessoryView(for text: String, theme: ElementsAppearance) -> UIView? { + paymentMethod.cardDetails.map { cardDetails in + let image = STPImageLibrary.cardBrandImage(for: cardDetails.stpBrand) + return UIImageView(image: image) + } + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Button+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Button+Link.swift new file mode 100644 index 00000000000..383afb5370c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Button+Link.swift @@ -0,0 +1,70 @@ +// +// Button+Link.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 12/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +extension Button.Configuration { + + static func linkPrimary() -> Self { + var configuration: Button.Configuration = .primary() + configuration.font = LinkUI.font(forTextStyle: .bodyEmphasized) + configuration.insets = LinkUI.buttonMargins + configuration.cornerRadius = LinkUI.cornerRadius + + // Colors + configuration.foregroundColor = .linkPrimaryButtonForeground + configuration.backgroundColor = .linkBrand + configuration.disabledBackgroundColor = .linkBrand + + configuration.colorTransforms.disabledForeground = .setAlpha(amount: 0.5) + configuration.colorTransforms.highlightedForeground = .darken(amount: 0.2) + + return configuration + } + + static func linkSecondary() -> Self { + var configuration: Button.Configuration = .linkPrimary() + + // Colors + configuration.foregroundColor = .linkSecondaryButtonForeground + configuration.backgroundColor = .linkSecondaryButtonBackground + configuration.disabledBackgroundColor = .linkSecondaryButtonBackground + + return configuration + } + + static func linkPlain() -> Self { + var configuration: Button.Configuration = .plain() + configuration.font = LinkUI.font(forTextStyle: .body) + configuration.foregroundColor = .linkBrandDark + configuration.disabledForegroundColor = nil + configuration.colorTransforms.highlightedForeground = .setAlpha(amount: 0.4) + configuration.colorTransforms.disabledForeground = .setAlpha(amount: 0.3) + return configuration + } + + static func linkBordered() -> Self { + var configuration: Button.Configuration = .plain() + configuration.font = LinkUI.font(forTextStyle: .detailEmphasized) + configuration.insets = .insets(top: 4, leading: 12, bottom: 4, trailing: 12) + configuration.borderWidth = 1 + configuration.cornerRadius = LinkUI.mediumCornerRadius + + // Colors + configuration.foregroundColor = .label + configuration.backgroundColor = .clear + configuration.borderColor = .linkControlBorder + + configuration.colorTransforms.highlightedForeground = .setAlpha(amount: 0.5) + configuration.colorTransforms.highlightedBorder = .setAlpha(amount: 0.5) + + return configuration + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/ConfirmButton+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/ConfirmButton+Link.swift new file mode 100644 index 00000000000..ab6795a9c91 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/ConfirmButton+Link.swift @@ -0,0 +1,39 @@ +// +// ConfirmButton+Link.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 1/12/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension ConfirmButton { + + static func makeLinkButton( + callToAction: CallToActionType, + compact: Bool = false, + didTap: @escaping () -> Void + ) -> ConfirmButton { + let button = ConfirmButton( + callToAction: callToAction, + appearance: LinkUI.appearance, + didTap: didTap + ) + + // Override the background color of the `.succeeded` state. Make it match + // the background color of the `.enabled` state. + // TODO(link): Needs refactor +// button.succeededBackgroundColor = ( +// LinkUI.appearance.primaryButton.backgroundColor ?? +// LinkUI.appearance.colors.primary +// ) + + button.directionalLayoutMargins = compact + ? LinkUI.compactButtonMargins + : LinkUI.buttonMargins + + return button + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift index d3bbf88d194..82e58090ea6 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift @@ -22,6 +22,10 @@ extension STPElementsSession { supportsLink && (linkFundingSources?.contains(.card) ?? false) || linkPassthroughModeEnabled } + var onlySupportsLinkBank: Bool { + return supportsLink && (linkFundingSources == [.bankAccount]) + } + var linkFundingSources: Set? { linkSettings?.fundingSources } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/PaymentSheet-Configuration+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/PaymentSheet-Configuration+Link.swift new file mode 100644 index 00000000000..0166b5d3645 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/PaymentSheet-Configuration+Link.swift @@ -0,0 +1,17 @@ +// +// PaymentSheet-Configuration+Link.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 2/18/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore + +extension PaymentSheet.Configuration { + + var isApplePayEnabled: Bool { + return StripeAPI.deviceSupportsApplePay() && self.applePay != nil + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/STPAnalyticsClient+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/STPAnalyticsClient+Link.swift index f953a17e4f0..b4176beaec6 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/STPAnalyticsClient+Link.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/STPAnalyticsClient+Link.swift @@ -67,6 +67,28 @@ extension STPAnalyticsClient { self.logPaymentSheetEvent(event: .linkAccountLookupFailure, error: error) } + // MARK: - 2FA + + func logLink2FAStart() { + self.logPaymentSheetEvent(event: .link2FAStart) + } + + func logLink2FAStartFailure() { + self.logPaymentSheetEvent(event: .link2FAStartFailure) + } + + func logLink2FAComplete() { + self.logPaymentSheetEvent(event: .link2FAComplete) + } + + func logLink2FAFailure() { + self.logPaymentSheetEvent(event: .link2FAFailure) + } + + func logLink2FACancel() { + self.logPaymentSheetEvent(event: .link2FACancel) + } + // MARK: - popup func logLinkPopupShow(sessionType: LinkSettings.PopupWebviewOption) { AnalyticsHelper.shared.startTimeMeasurement(.linkPopup) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/UIColor+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/UIColor+Link.swift index 8c8a3b55dd6..be519121811 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/UIColor+Link.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/UIColor+Link.swift @@ -21,6 +21,17 @@ extension UIColor { UIColor(red: 0, green: 0.84, blue: 0.44, alpha: 1.0) } + /// Darker version of the brand color. + /// + /// Use it as accent color on small UI elements or text links. + static let linkBrandDark: UIColor = UIColor(red: 0.020, green: 0.659, blue: 0.498, alpha: 1.0) + + /// Main background color. + static let linkBackground: UIColor = .dynamic( + light: .white, + dark: UIColor(red: 0.11, green: 0.11, blue: 0.118, alpha: 1) + ) + /// Level 400 variant of Link brand color. /// /// Use for separator bars over the Link brand color. @@ -34,11 +45,87 @@ extension UIColor { dark: .white ) + /// Tint color of the nav. Affects the color of nav buttons. + static let linkNavTint: UIColor = .dynamic( + light: UIColor(red: 0.188, green: 0.192, blue: 0.239, alpha: 1.0), + dark: UIColor(red: 0.922, green: 0.922, blue: 0.961, alpha: 0.6) + ) + + /// Color for borders and dividers. + static let linkSeparator: UIColor = .dynamic( + light: UIColor(red: 0.878, green: 0.902, blue: 0.922, alpha: 1), + dark: UIColor(red: 0.471, green: 0.471, blue: 0.502, alpha: 0.36) + ) + + /// Border color for custom controls. Currently an alias of `linkSeparator`. + static let linkControlBorder: UIColor = .linkSeparator + + /// Background color for custom controls. + static let linkControlBackground: UIColor = .dynamic( + light: .white, + dark: UIColor(red: 0.17, green: 0.17, blue: 0.19, alpha: 1) + ) + + /// Background color to be used when a custom control is highlighted. + static let linkControlHighlight: UIColor = .dynamic( + light: UIColor(red: 0.95, green: 0.95, blue: 0.96, alpha: 1), + dark: UIColor(white: 1, alpha: 0.07) + ) + + /// A very subtle color to be used on placeholder content of a control. + /// + /// - Note: Only recommended for shapes/non-text content due to very low contrast ratio with `linkControlBackground`. + static let linkControlLightPlaceholder: UIColor = .dynamic( + light: UIColor(red: 0.922, green: 0.933, blue: 0.945, alpha: 1.0), + dark: UIColor(red: 0.471, green: 0.471, blue: 0.502, alpha: 0.36) + ) + + /// Background color of the toast component. + static let linkToastBackground: UIColor = UIColor(red: 0.19, green: 0.19, blue: 0.24, alpha: 1.0) + + /// Foreground color of the toast component. + static let linkToastForeground: UIColor = .white + /// Foreground color of the primary button. static var linkPrimaryButtonForeground: UIColor { UIColor(red: 0, green: 0.12, blue: 0.06, alpha: 1.0) } + /// Foreground color of the secondary button. + static let linkSecondaryButtonForeground: UIColor = .dynamic( + light: UIColor(red: 0.114, green: 0.224, blue: 0.267, alpha: 1.0), + dark: UIColor(red: 0.020, green: 0.659, blue: 0.498, alpha: 1.0) + ) + + /// Background color of the secondary button/ + static let linkSecondaryButtonBackground: UIColor = .dynamic( + light: UIColor(red: 0.965, green: 0.973, blue: 0.980, alpha: 1.0), + dark: UIColor(red: 0.455, green: 0.455, blue: 0.502, alpha: 0.18) + ) + + /// Background color of a neutral badge or notice. + static let linkNeutralBackground: UIColor = .dynamic( + light: UIColor(red: 0.96, green: 0.97, blue: 0.98, alpha: 1.0), + dark: UIColor(white: 1, alpha: 0.1) + ) + + /// Foreground color of a neutral badge or notice. + static let linkNeutralForeground: UIColor = .dynamic( + light: UIColor(red: 0.416, green: 0.451, blue: 0.514, alpha: 1), + dark: UIColor(red: 0.922, green: 0.922, blue: 0.961, alpha: 0.6) + ) + + /// Background color of an error badge or notice. + static let linkDangerBackground: UIColor = .dynamic( + light: UIColor(red: 1.0, green: 0.906, blue: 0.949, alpha: 1.0), + dark: UIColor(red: 0.996, green: 0.529, blue: 0.631, alpha: 0.1) + ) + + /// Foreground color of an error badge or notice. + static let linkDangerForeground: UIColor = .dynamic( + light: UIColor(red: 1.0, green: 0.184, blue: 0.298, alpha: 1.0), + dark: UIColor(red: 1.0, green: 0.184, blue: 0.298, alpha: 1.0) + ) } // MARK: - Text color diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/LinkUI.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/LinkUI.swift index 13b9a403aef..671a5704f4f 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/LinkUI.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/LinkUI.swift @@ -143,3 +143,36 @@ extension LinkUI { } } + +// MARK: - Appearance + +extension LinkUI { + + static let appearance: PaymentSheet.Appearance = { + var appearance = PaymentSheet.Appearance.default + appearance.cornerRadius = LinkUI.mediumCornerRadius + appearance.colors.primary = .linkBrandDark + appearance.colors.background = .linkBackground + + // Text + appearance.colors.text = .linkPrimaryText + appearance.colors.textSecondary = .linkSecondaryText + + // Components + appearance.colors.componentText = .linkPrimaryText + appearance.colors.componentPlaceholderText = .linkSecondaryText + appearance.colors.componentBackground = .linkControlBackground + appearance.colors.componentBorder = .linkControlBorder + appearance.colors.componentDivider = .linkSeparator + + // Primary button + appearance.primaryButton.textColor = .linkPrimaryButtonForeground + appearance.primaryButton.backgroundColor = .linkBrand + appearance.primaryButton.borderWidth = 0 + appearance.primaryButton.cornerRadius = LinkUI.cornerRadius + appearance.primaryButton.font = LinkUI.font(forTextStyle: .bodyEmphasized) + + return appearance + }() + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Services/LinkAccountService.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Services/LinkAccountService.swift index fa1a1896767..8f5413d0058 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Services/LinkAccountService.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Services/LinkAccountService.swift @@ -33,11 +33,17 @@ protocol LinkAccountServiceProtocol { final class LinkAccountService: LinkAccountServiceProtocol { let apiClient: STPAPIClient + let cookieStore: LinkCookieStore + + /// The default cookie store used by new instances of the service. + static var defaultCookieStore: LinkCookieStore = LinkSecureCookieStore.shared init( - apiClient: STPAPIClient = .shared + apiClient: STPAPIClient = .shared, + cookieStore: LinkCookieStore = defaultCookieStore ) { self.apiClient = apiClient + self.cookieStore = cookieStore } func lookupAccount( @@ -83,4 +89,16 @@ final class LinkAccountService: LinkAccountServiceProtocol { } } } + + func hasEmailLoggedOut(email: String) -> Bool { + guard let hashedEmail = email.lowercased().sha256 else { + return false + } + + return cookieStore.read(key: .lastLogoutEmail) == hashedEmail + } + + func getLastSignUpEmail() -> String? { + return cookieStore.read(key: .lastSignupEmail) + } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkCookieKey.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkCookieKey.swift index f93d9b1ac9e..aca7278cf4b 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkCookieKey.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkCookieKey.swift @@ -7,9 +7,9 @@ // enum LinkCookieKey: String { - case session = "com.stripe.pay_sid" case lastLogoutEmail = "com.stripe.link_account" case lastPMLast4 = "com.stripe.link.last_pm_last4" case lastPMBrand = "com.stripe.link.last_pm_brand" case hasUsedLink = "com.stripe.link.has_used_link" + case lastSignupEmail = "com.stripe.link.last_signup_email" } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkUtils.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkUtils.swift new file mode 100644 index 00000000000..657b473fe2b --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkUtils.swift @@ -0,0 +1,51 @@ +// +// LinkUtils.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 6/24/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) import StripeCore + +final class LinkUtils { + + /// Error codes for the consumer/Link API. + enum ConsumerErrorCode: String { + case consumerVerificationCodeInvalid = "consumer_verification_code_invalid" + case consumerVerificationExpired = "consumer_verification_expired" + case consumerVerificationMaxAttemptsExceeded = "consumer_verification_max_attempts_exceeded" + + var localizedDescription: String { + switch self { + case .consumerVerificationCodeInvalid: + return STPLocalizedString( + "The provided verification code is incorrect.", + "Error message shown when the user enters an incorrect verification code." + ) + case .consumerVerificationExpired: + return STPLocalizedString( + "The provided verification code has expired.", + "Error message shown when the user enters an expired verification code." + ) + case .consumerVerificationMaxAttemptsExceeded: + return STPLocalizedString( + "Too many attempts. Please try again in a few minutes.", + "Error message shown when the user enters an incorrect verification code too many times." + ) + } + } + } + + static func getLocalizedErrorMessage(from error: Error) -> String { + guard let errorCodeString = (error as NSError).userInfo[STPError.stripeErrorCodeKey] as? String, + let errorCode = ConsumerErrorCode(rawValue: errorCodeString) else { + return error.localizedDescription + } + + return errorCode.localizedDescription + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationController.swift new file mode 100644 index 00000000000..8de873a7703 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationController.swift @@ -0,0 +1,49 @@ +// +// LinkVerificationController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 7/23/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// Standalone verification controller. +final class LinkVerificationController { + + typealias CompletionBlock = (LinkVerificationViewController.VerificationResult) -> Void + + private var completion: CompletionBlock? + + private var selfRetainer: LinkVerificationController? + private let verificationViewController: LinkVerificationViewController + + init(mode: LinkVerificationView.Mode = .modal, linkAccount: PaymentSheetLinkAccount) { + self.verificationViewController = LinkVerificationViewController(mode: mode, linkAccount: linkAccount) + verificationViewController.delegate = self + } + + func present( + from presentingController: UIViewController, + completion: @escaping CompletionBlock + ) { + self.selfRetainer = self + self.completion = completion + presentingController.present(verificationViewController, animated: true) + } + +} + +extension LinkVerificationController: LinkVerificationViewControllerDelegate { + + func verificationController( + _ controller: LinkVerificationViewController, + didFinishWithResult result: LinkVerificationViewController.VerificationResult + ) { + controller.dismiss(animated: true) { [weak self] in + self?.completion?(result) + self?.selfRetainer = nil + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-Header.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-Header.swift new file mode 100644 index 00000000000..a506189345c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-Header.swift @@ -0,0 +1,69 @@ +// +// LinkVerificationView-Header.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 12/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore +import UIKit + +extension LinkVerificationView { + + final class Header: UIView { + struct Constants { + static let logoHeight: CGFloat = 18 + } + + private let logoView: UIImageView = { + let logoView = UIImageView(image: Image.link_logo.makeImage(template: true)) + logoView.translatesAutoresizingMaskIntoConstraints = false + logoView.isAccessibilityElement = true + logoView.accessibilityTraits = .header + logoView.accessibilityLabel = STPPaymentMethodType.link.displayName + return logoView + }() + + let closeButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(Image.icon_cancel.makeImage(template: true), for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = String.Localized.close + return button + }() + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: 24) + } + + init() { + super.init(frame: .zero) + + addSubview(logoView) + addSubview(closeButton) + + NSLayoutConstraint.activate([ + // Logo + logoView.centerXAnchor.constraint(equalTo: centerXAnchor), + logoView.centerYAnchor.constraint(equalTo: centerYAnchor), + logoView.heightAnchor.constraint(equalToConstant: Constants.logoHeight), + + // Button + closeButton.topAnchor.constraint(equalTo: topAnchor), + closeButton.rightAnchor.constraint(equalTo: rightAnchor), + closeButton.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + tintColor = .linkNavTint + logoView.tintColor = .linkNavLogo + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-LogoutView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-LogoutView.swift new file mode 100644 index 00000000000..0bafc76033c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView-LogoutView.swift @@ -0,0 +1,65 @@ +// +// LinkVerificationView-LogoutView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 2/4/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +extension LinkVerificationView { + + final class LogoutView: UIView { + let linkAccount: PaymentSheetLinkAccountInfoProtocol + + private let font: UIFont = LinkUI.font(forTextStyle: .detail) + + private lazy var label: UILabel = { + let label = UILabel() + label.font = font + label.adjustsFontForContentSizeCategory = true + label.textColor = .linkSecondaryText + label.lineBreakMode = .byTruncatingMiddle + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.text = String( + format: STPLocalizedString( + "Not %@?", + "Text of a label for confirming an email address. E.g., 'Not user@example.com?'" + ), + linkAccount.email + ) + return label + }() + + private(set) lazy var button: Button = { + let button = Button(configuration: .linkPlain(), title: STPLocalizedString( + "Change email", + "Title for a button that allows the user to use a different email in the signup flow." + )) + button.configuration.font = font + return button + }() + + init(linkAccount: PaymentSheetLinkAccountInfoProtocol) { + self.linkAccount = linkAccount + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + let stackView = UIStackView(arrangedSubviews: [label, button]) + stackView.spacing = LinkUI.tinyContentSpacing + stackView.alignment = .center + stackView.distribution = .equalSpacing + addAndPinSubview(stackView) + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView.swift new file mode 100644 index 00000000000..77ff077d193 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationView.swift @@ -0,0 +1,271 @@ +// +// LinkVerificationView.swift +// StripePaymentSheet +// +// Created by Cameron Sabol on 3/24/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore + +/// :nodoc: +protocol LinkVerificationViewDelegate: AnyObject { + func verificationViewDidCancel(_ view: LinkVerificationView) + func verificationViewResendCode(_ view: LinkVerificationView) + func verificationViewLogout(_ view: LinkVerificationView) + func verificationView(_ view: LinkVerificationView, didEnterCode code: String) +} + +/// For internal SDK use only +@objc(STP_Internal_LinkVerificationView) +final class LinkVerificationView: UIView { + struct Constants { + static let edgeMargin: CGFloat = 20 + } + + enum Mode { + case modal + case inlineLogin + case embedded + } + + weak var delegate: LinkVerificationViewDelegate? + + private let mode: Mode + + let linkAccount: PaymentSheetLinkAccountInfoProtocol + + var sendingCode: Bool = false { + didSet { + resendCodeButton.isLoading = sendingCode + } + } + + var errorMessage: String? { + didSet { + errorLabel.text = errorMessage + errorLabel.setHiddenIfNecessary(errorMessage == nil) + } + } + + private lazy var header: Header = { + let header = Header() + header.closeButton.addTarget(self, action: #selector(didSelectCancel), for: .touchUpInside) + return header + }() + + private lazy var headingLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.font = LinkUI.font(forTextStyle: .title) + label.textColor = .linkPrimaryText + label.text = mode.headingText + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var bodyLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.font = mode.bodyFont + label.textColor = .linkSecondaryText + label.text = mode.bodyText(redactedPhoneNumber: linkAccount.redactedPhoneNumber ?? "") + label.adjustsFontForContentSizeCategory = true + return label + }() + + private(set) lazy var codeField: OneTimeCodeTextField = { + let codeField = OneTimeCodeTextField(configuration: + .init(numberOfDigits: 6), + theme: LinkUI.appearance.asElementsTheme) + codeField.addTarget(self, action: #selector(oneTimeCodeFieldChanged(_:)), for: .valueChanged) + return codeField + }() + + private let errorLabel: UILabel = { + let label = UILabel() + label.font = LinkUI.font(forTextStyle: .detail) + label.textColor = .systemRed + label.numberOfLines = 0 + label.textAlignment = .center + label.isHidden = true + return label + }() + + private lazy var codeFieldContainer: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [codeField, errorLabel]) + stackView.axis = .vertical + stackView.spacing = LinkUI.smallContentSpacing + return stackView + }() + + private lazy var resendCodeButton: Button = { + let button = Button(configuration: .linkBordered(), title: STPLocalizedString( + "Resend code", + "Label for a button that re-sends the a login code when tapped" + )) + button.addTarget(self, action: #selector(resendCodeTapped(_:)), for: .touchUpInside) + return button + }() + + private lazy var logoutView: LogoutView = { + let logoutView = LogoutView(linkAccount: linkAccount) + logoutView.button.addTarget(self, action: #selector(didTapOnLogout(_:)), for: .touchUpInside) + return logoutView + }() + + required init(mode: Mode, linkAccount: PaymentSheetLinkAccountInfoProtocol) { + self.mode = mode + self.linkAccount = linkAccount + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc + func oneTimeCodeFieldChanged(_ sender: OneTimeCodeTextField) { + // Clear error message when the field changes. + errorMessage = nil + + if sender.isComplete { + delegate?.verificationView(self, didEnterCode: sender.value) + } + } + + @objc + func didSelectCancel() { + delegate?.verificationViewDidCancel(self) + } + + @objc + func didTapOnLogout(_ sender: UIButton) { + delegate?.verificationViewLogout(self) + } + + @objc + func resendCodeTapped(_ sender: UIButton) { + delegate?.verificationViewResendCode(self) + } +} + +private extension LinkVerificationView { + + var arrangedSubViews: [UIView] { + switch mode { + case .modal, .inlineLogin: + return [ + header, + headingLabel, + bodyLabel, + codeFieldContainer, + resendCodeButton, + ] + case .embedded: + return [ + headingLabel, + bodyLabel, + codeFieldContainer, + logoutView, + resendCodeButton, + ] + } + } + + func setupUI() { + directionalLayoutMargins = .insets(amount: Constants.edgeMargin) + + let stackView = UIStackView(arrangedSubviews: arrangedSubViews) + stackView.spacing = LinkUI.smallContentSpacing + stackView.axis = .vertical + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + + // Spacing + stackView.setCustomSpacing(Constants.edgeMargin, after: header) + stackView.setCustomSpacing(LinkUI.extraLargeContentSpacing, after: bodyLabel) + stackView.setCustomSpacing(LinkUI.largeContentSpacing, after: codeFieldContainer) + + addSubview(stackView) + + var constraints: [NSLayoutConstraint] = [ + // Stack view + stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + + // OTC field + codeFieldContainer.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + codeFieldContainer.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + ] + + if mode.requiresModalPresentation { + constraints.append(contentsOf: [ + // Header + header.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + header.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + ]) + } + + NSLayoutConstraint.activate(constraints) + backgroundColor = .systemBackground + } +} + +extension LinkVerificationView.Mode { + + var requiresModalPresentation: Bool { + switch self { + case .modal, .inlineLogin: + return true + case .embedded: + return false + } + } + + var headingText: String { + switch self { + case .modal: + return STPLocalizedString( + "Use your saved info to check out faster", + "Two factor authentication screen heading" + ) + case .inlineLogin: + return STPLocalizedString( + "Sign in to your Link account", + "Two factor authentication screen heading" + ) + case .embedded: + return STPLocalizedString( + "Enter your verification code", + "Two factor authentication screen heading" + ) + } + } + + var bodyFont: UIFont { + switch self { + case .modal, .inlineLogin: + return LinkUI.font(forTextStyle: .detail) + case .embedded: + return LinkUI.font(forTextStyle: .body) + } + } + + func bodyText(redactedPhoneNumber: String) -> String { + let format = STPLocalizedString( + "Enter the code sent to %@ to use Link to pay by default.", + "Instructs the user to enter the code sent to their phone number in order to login to Link" + ) + return String(format: format, redactedPhoneNumber) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController-PresentationController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController-PresentationController.swift new file mode 100644 index 00000000000..fbd2417c131 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController-PresentationController.swift @@ -0,0 +1,211 @@ +// +// LinkVerificationViewController-PresentationController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 11/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +extension LinkVerificationViewController { + + /// For internal SDK use only + @objc(STP_Internal_LinkPresentationController) + final class PresentationController: UIPresentationController { + struct Constants { + static let padding: CGFloat = 16 + static let maxWidth: CGFloat = 400 + static let maxHeight: CGFloat = 410 + static let targetHeight: CGFloat = 332 + } + + /// A bottom inset necessary for the presented view to avoid the software keyboard. + private var bottomInset: CGFloat = 0 + + /// An area where it is safe to present the modal on. + /// + /// This is always equals to the container view safe area minus `padding` on eat edge. + private var safeFrame: CGRect { + guard let containerView = containerView else { + return .zero + } + + return containerView.bounds + .inset(by: containerView.safeAreaInsets) + .insetBy(dx: Constants.padding, dy: Constants.padding) + } + + private lazy var dimmingView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + return view + }() + + private var contentView: UIView? { + if let scrollView = presentedView as? UIScrollView { + return scrollView.subviews.first + } + + return presentedView + } + + override var frameOfPresentedViewInContainerView: CGRect { + guard let containerView = containerView else { + return .zero + } + + return calculateModalFrame(forContainerSize: containerView.bounds.size) + } + + func updatePresentedViewFrame() { + presentedView?.frame = frameOfPresentedViewInContainerView + } + + private func calculateModalFrame(forContainerSize containerSize: CGSize) -> CGRect { + guard let contentView = contentView else { + return .zero + } + + let targetSize = CGSize( + width: min(Constants.maxWidth, safeFrame.width), + height: Constants.targetHeight + ) + + let fittingSize = contentView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .defaultLow + ) + + let actualSize = CGSize( + width: fittingSize.width, + height: min(fittingSize.height, Constants.maxHeight) + ) + + return CGRect( + x: (containerSize.width - actualSize.width) / 2, + y: max((containerSize.height - actualSize.height - bottomInset) / 2, Constants.padding), + width: actualSize.width, + height: actualSize.height + ).integral + } + + override func containerViewWillLayoutSubviews() { + super.containerViewWillLayoutSubviews() + + guard let containerView = containerView else { + return + } + + dimmingView.frame = containerView.bounds + presentedView?.frame = frameOfPresentedViewInContainerView + } + + override func presentationTransitionWillBegin() { + super.presentationTransitionWillBegin() + + guard let containerView = containerView, + let transitionCoordinator = presentedViewController.transitionCoordinator else { + return + } + + containerView.insertSubview(dimmingView, at: 0) + dimmingView.frame = containerView.bounds + dimmingView.alpha = 0 + + transitionCoordinator.animate { _ in + self.dimmingView.alpha = 1 + } + } + + override func dismissalTransitionWillBegin() { + super.dismissalTransitionWillBegin() + guard let transitionCoordinator = presentedViewController.transitionCoordinator else { + return + } + + transitionCoordinator.animate( + alongsideTransition: { _ in + self.dimmingView.alpha = 0.0 + }, + completion: { _ in + self.dimmingView.removeFromSuperview() + } + ) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { context in + self.presentedView?.frame = self.calculateModalFrame( + forContainerSize: context.containerView.bounds.size + ) + } + } + + override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) + + coordinator.animate { context in + self.presentedView?.frame = self.calculateModalFrame( + forContainerSize: context.containerView.bounds.size + ) + } + } + + override init( + presentedViewController: UIViewController, + presenting presentingViewController: UIViewController? + ) { + super.init(presentedViewController: presentedViewController, presenting: presentingViewController) + + NotificationCenter.default.addObserver(self, + selector: #selector(keyboardFrameChanged(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + } + +} + +// MARK: - Keyboard handling + +extension LinkVerificationViewController.PresentationController { + + @objc func keyboardFrameChanged(_ notification: Notification) { + let userInfo = notification.userInfo + + guard let keyboardFrame = userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let containerView = containerView else { + return + } + + let absoluteFrame = containerView.convert(safeFrame, to: containerView.window) + let intersection = absoluteFrame.intersection(keyboardFrame) + + UIView.animateAlongsideKeyboard(notification) { + self.bottomInset = intersection.height + self.presentedView?.frame = self.frameOfPresentedViewInContainerView + } + } + + @objc func keyboardWillHide(_ notification: Notification) { + UIView.animateAlongsideKeyboard(notification) { + self.bottomInset = 0 + self.presentedView?.frame = self.frameOfPresentedViewInContainerView + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController.swift new file mode 100644 index 00000000000..5f4f09b5267 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Verification/LinkVerificationViewController.swift @@ -0,0 +1,247 @@ +// +// LinkVerificationViewController.swift +// StripePaymentSheet +// +// Created by Cameron Sabol on 3/24/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore + +protocol LinkVerificationViewControllerDelegate: AnyObject { + func verificationController( + _ controller: LinkVerificationViewController, + didFinishWithResult result: LinkVerificationViewController.VerificationResult + ) +} + +/// For internal SDK use only +@objc(STP_Internal_LinkVerificationViewController) +final class LinkVerificationViewController: UIViewController { + enum VerificationResult { + /// Verification was completed successfully. + case completed + /// Verification was canceled by the user. + case canceled + /// Verification failed due to an unrecoverable error. + case failed(Error) + } + + weak var delegate: LinkVerificationViewControllerDelegate? + + let mode: LinkVerificationView.Mode + let linkAccount: PaymentSheetLinkAccount + + private lazy var verificationView: LinkVerificationView = { + guard linkAccount.redactedPhoneNumber != nil else { + preconditionFailure("Verification(2FA) presented without a phone number on file") + } + + let verificationView = LinkVerificationView(mode: mode, linkAccount: linkAccount) + verificationView.delegate = self + verificationView.backgroundColor = .clear + verificationView.translatesAutoresizingMaskIntoConstraints = false + + return verificationView + }() + + private lazy var activityIndicator: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + return activityIndicator + }() + + private lazy var scrollView = LinkKeyboardAvoidingScrollView() + + required init( + mode: LinkVerificationView.Mode = .modal, + linkAccount: PaymentSheetLinkAccount + ) { + self.mode = mode + self.linkAccount = linkAccount + super.init(nibName: nil, bundle: nil) + + if mode.requiresModalPresentation { + modalPresentationStyle = .custom + transitioningDelegate = TransitioningDelegate.sharedDelegate + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = scrollView + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.tintColor = .linkBrand + view.backgroundColor = .systemBackground + + view.addSubview(verificationView) + view.addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + // Verification view + verificationView.topAnchor.constraint(equalTo: view.topAnchor), + verificationView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + verificationView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + verificationView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + verificationView.widthAnchor.constraint(equalTo: view.widthAnchor), + // Activity indicator + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + if mode.requiresModalPresentation { + view.layer.masksToBounds = true + view.layer.cornerRadius = LinkUI.cornerRadius + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if let presentationController = presentationController as? PresentationController { + presentationController.updatePresentedViewFrame() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + activityIndicator.startAnimating() + + if linkAccount.sessionState == .requiresVerification { + verificationView.isHidden = true + + linkAccount.startVerification { [weak self] result in + switch result { + case .success(let collectOTP): + if collectOTP { + self?.activityIndicator.stopAnimating() + self?.verificationView.isHidden = false + self?.verificationView.codeField.becomeFirstResponder() + } else { + // No OTP collection is required. + self?.finish(withResult: .completed) + } + case .failure(let error): + STPAnalyticsClient.sharedClient.logLink2FAStartFailure() + self?.finish(withResult: .failed(error)) + } + } + } else { + verificationView.codeField.becomeFirstResponder() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + STPAnalyticsClient.sharedClient.logLink2FAStart() + } + +} + +/// :nodoc: +extension LinkVerificationViewController: LinkVerificationViewDelegate { + + func verificationViewDidCancel(_ view: LinkVerificationView) { + // Mark email as logged out to prevent automatically showing + // the 2FA modal in future checkout sessions. + linkAccount.markEmailAsLoggedOut() + + STPAnalyticsClient.sharedClient.logLink2FACancel() + finish(withResult: .canceled) + } + + func verificationViewResendCode(_ view: LinkVerificationView) { + view.sendingCode = true + view.errorMessage = nil + + // To resend the code we just start a new verification session. + linkAccount.startVerification { [weak self] (result) in + view.sendingCode = false + + switch result { + case .success: + let toast = LinkToast( + type: .success, + text: STPLocalizedString( + "Code sent", + "Text of a notification shown to the user when a login code is successfully sent via SMS." + ) + ) + toast.show(from: view) + case .failure(let error): + let alertController = UIAlertController( + title: nil, + message: error.localizedDescription, + preferredStyle: .alert + ) + + alertController.addAction(UIAlertAction( + title: String.Localized.ok, + style: .default + )) + + self?.present(alertController, animated: true) + } + } + } + + func verificationViewLogout(_ view: LinkVerificationView) { + STPAnalyticsClient.sharedClient.logLink2FACancel() + finish(withResult: .canceled) + } + + func verificationView(_ view: LinkVerificationView, didEnterCode code: String) { + view.codeField.resignFirstResponder() + + linkAccount.verify(with: code) { [weak self] result in + switch result { + case .success: + self?.finish(withResult: .completed) + STPAnalyticsClient.sharedClient.logLink2FAComplete() + case .failure(let error): + view.codeField.performInvalidCodeAnimation() + view.errorMessage = LinkUtils.getLocalizedErrorMessage(from: error) + STPAnalyticsClient.sharedClient.logLink2FAFailure() + } + } + } + +} + +extension LinkVerificationViewController { + + private func finish(withResult result: VerificationResult) { + // Delete the last "signup email" cookie, if any, after the user completes or declines verification. + LinkAccountService.defaultCookieStore.delete(key: .lastSignupEmail) + delegate?.verificationController(self, didFinishWithResult: result) + } + +} + +// MARK: - Transitioning Delegate + +extension LinkVerificationViewController { + + final class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate { + static let sharedDelegate: TransitioningDelegate = TransitioningDelegate() + + func presentationController(forPresented presented: UIViewController, + presenting: UIViewController?, + source: UIViewController) -> UIPresentationController? { + return PresentationController(presentedViewController: presented, + presenting: presenting) + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Views/LinkInstantDebitMandateView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Views/LinkInstantDebitMandateView.swift new file mode 100644 index 00000000000..7ae8ce58ce0 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Views/LinkInstantDebitMandateView.swift @@ -0,0 +1,121 @@ +// +// LinkInstantDebitMandateView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 2/17/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +protocol LinkInstantDebitMandateViewDelegate: AnyObject { + /// Called when the user taps on a link. + /// + /// - Parameters: + /// - mandateView: The view that the user interacted with. + /// - url: URL of the link. + func instantDebitMandateView(_ mandateView: LinkInstantDebitMandateView, didTapOnLinkWithURL url: URL) +} + +// TODO(ramont): extract common code with `LinkLegalTermsView`. + +/// For internal SDK use only +@objc(STP_Internal_LinkInstantDebitMandateViewDelegate) +final class LinkInstantDebitMandateView: UIView { + struct Constants { + static let lineHeight: CGFloat = 1.5 + } + + // TODO(ramont): Update with final URLs + private let links: [String: URL] = [ + "terms": URL(string: "https://stripe.com/ach-payments/authorization")! + ] + + weak var delegate: LinkInstantDebitMandateViewDelegate? + + private lazy var textView: UITextView = { + let textView = UITextView() + textView.isScrollEnabled = false + textView.isEditable = false + textView.backgroundColor = .clear + textView.attributedText = formattedLegalText() + textView.textColor = .linkSecondaryText + textView.textAlignment = .center + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.delegate = self + textView.clipsToBounds = false + textView.adjustsFontForContentSizeCategory = true + textView.linkTextAttributes = [ + .foregroundColor: UIColor.linkBrandDark + ] + textView.font = LinkUI.font(forTextStyle: .caption) + return textView + }() + + init(delegate: LinkInstantDebitMandateViewDelegate? = nil) { + super.init(frame: .zero) + self.delegate = delegate + addAndPinSubview(textView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func formattedLegalText() -> NSAttributedString { + let string = STPLocalizedString( + "By continuing, you agree to authorize payments pursuant to these terms.", + "Mandate text displayed when paying via Link instant debit." + ) + + let formattedString = NSMutableAttributedString() + + STPStringUtils.parseRanges(from: string, withTags: Set(links.keys)) { string, matches in + formattedString.append(NSAttributedString(string: string)) + + for (tag, range) in matches { + guard range.rangeValue.location != NSNotFound else { + assertionFailure("Tag '<\(tag)>' not found") + continue + } + + if let url = links[tag] { + formattedString.addAttributes([.link: url], range: range.rangeValue) + } + } + } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = LinkUI.lineSpacing( + fromRelativeHeight: Constants.lineHeight, + textStyle: .caption + ) + + formattedString.addAttributes([.paragraphStyle: paragraphStyle], range: formattedString.extent) + + return formattedString + } + +} + +extension LinkInstantDebitMandateView: UITextViewDelegate { + + #if !os(visionOS) + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + if interaction == .invokeDefaultAction { + delegate?.instantDebitMandateView(self, didTapOnLinkWithURL: URL) + } + + return false + } + #endif + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Views/LinkKeyboardAvoidingScrollView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Views/LinkKeyboardAvoidingScrollView.swift new file mode 100644 index 00000000000..ba793bacfd3 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Views/LinkKeyboardAvoidingScrollView.swift @@ -0,0 +1,75 @@ +// +// LinkKeyboardAvoidingScrollView.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 1/11/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +/// A UIScrollView subclass that actively prevents its content from being covered by the software keyboard. +/// For internal SDK use only +@objc(STP_Internal_LinkKeyboardAvoidingScrollView) +final class LinkKeyboardAvoidingScrollView: UIScrollView { + + init() { + super.init(frame: .zero) + + NotificationCenter.default.addObserver(self, + selector: #selector(keyboardFrameChanged(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil) + } + + /// Creates a new keyboard-avoiding scrollview with the given view configured as content view. + /// + /// This initializer adds the content view as a subview and installs the appropriate set of constraints. + /// + /// - Parameter contentView: The view to be used as content view. + convenience init(contentView: UIView) { + self.init() + + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.widthAnchor.constraint(equalTo: widthAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +// MARK: - Event Handling + +private extension LinkKeyboardAvoidingScrollView { + + @objc func keyboardFrameChanged(_ notification: Notification) { + let userInfo = notification.userInfo + + guard let keyboardFrame = userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return + } + + let absoluteFrame = convert(bounds, to: window) + let intersection = absoluteFrame.intersection(keyboardFrame) + + UIView.animateAlongsideKeyboard(notification) { + self.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: intersection.height, right: 0) + self.scrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: intersection.height, right: 0) + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift index 3a7c8468ca8..db00ceffa0a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift @@ -46,7 +46,7 @@ public class EmbeddedPaymentElement { public var paymentOption: PaymentOptionDisplayData? { return embeddedPaymentMethodsView.displayData } - + private let embeddedPaymentMethodsView: EmbeddedPaymentMethodsView /// An asynchronous failable initializer @@ -238,7 +238,7 @@ extension EmbeddedPaymentElement: EmbeddedPaymentMethodsViewDelegate { func heightDidChange() { delegate?.embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: self) } - + func selectionDidUpdate() { delegate?.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: self) } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/LinkPaymentController-New.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/LinkPaymentController-New.swift new file mode 100644 index 00000000000..8c0721c6778 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/LinkPaymentController-New.swift @@ -0,0 +1,223 @@ +//// +//// LinkPaymentController.swift +//// StripePaymentSheet +//// +//// Created by Bill Meltsner on 12/9/22. +//// Copyright © 2022 Stripe, Inc. All rights reserved. +//// +// +// @_spi(STP) import StripeCore +// @_spi(STP) import StripePayments +// @_spi(STP) import StripeUICore +// import UIKit +// +///// `LinkPaymentController` encapsulates the Link payment flow, allowing you to let your customers pay with their Link account. +///// This feature is currently invite-only. To accept payments, [use the Mobile Payment Element.](https://stripe.com/docs/payments/accept-a-payment?platform=ios&ui=payment-sheet) +// @available(iOSApplicationExtension, unavailable) +// @available(macCatalystApplicationExtension, unavailable) +// @_spi(LinkOnly) public class LinkNativePaymentController { +// +// private let mode: PaymentSheet.InitializationMode +// private let configuration: PaymentSheet.Configuration +// +// private var intent: Intent? +// private var payWithLinkContinuation: CheckedContinuation? +// private var paymentOption: PaymentOption? +// +// /// Initializes a new `LinkPaymentController` instance. +// /// - Parameter paymentIntentClientSecret: The [client secret](https://stripe.com/docs/api/payment_intents/object#payment_intent_object-client_secret) of a Stripe PaymentIntent object +// /// - Note: This can be used to complete a payment - don't log it, store it, or expose it to anyone other than the customer. +// /// - Parameter returnURL: A URL that redirects back to your app for flows that complete authentication in another app (such as a bank app). +// /// - Parameter billingDetails: Any information about the customer you've already collected. +// @_spi(LinkOnly) public convenience init(paymentIntentClientSecret: String, returnURL: String? = nil, billingDetails: PaymentSheet.BillingDetails? = nil) { +// self.init(intentSecret: .paymentIntentClientSecret(paymentIntentClientSecret), returnURL: returnURL, billingDetails: billingDetails) +// } +// +// /// Initializes a new `LinkPaymentController` instance. +// /// - Parameter setupIntentClientSecret: The [client secret](https://stripe.com/docs/api/payment_intents/object#payment_intent_object-client_secret) of a Stripe SetupIntent object +// /// - Parameter returnURL: A URL that redirects back to your app for flows that complete authentication in another app (such as a bank app). +// /// - Parameter billingDetails: Any information about the customer you've already collected. +// @_spi(LinkOnly) public convenience init(setupIntentClientSecret: String, returnURL: String? = nil, billingDetails: PaymentSheet.BillingDetails? = nil) { +// self.init(intentSecret: .setupIntentClientSecret(setupIntentClientSecret), returnURL: returnURL, billingDetails: billingDetails) +// } +// +// private init(intentSecret: PaymentSheet.InitializationMode, returnURL: String?, billingDetails: PaymentSheet.BillingDetails?) { +// self.mode = intentSecret +// var configuration = PaymentSheet.Configuration() +// configuration.linkPaymentMethodsOnly = true +// configuration.returnURL = returnURL +// if let billingDetails = billingDetails { +// configuration.defaultBillingDetails = billingDetails +// } +// self.configuration = configuration +// } +// +// /// Presents the Link payment flow, allowing your customer to pay with Link. +// /// The flow lets your customer log into or create a Link account, select a valid source of funds, and approve the usage of those funds to complete the purchase. The actual purchase will not occur until you call `confirm(from:completion:)`. +// /// - Note: Once `confirm(from:completion:)` completes successfully (i.e. when `result` is `.success`), calling this method is an error, as payment/setup intents should not be reused. Until then, you may call this method as many times as is necessary. +// /// - Parameter presentingViewController: The view controller to present the payment flow from. +// /// - Parameter completion: Called when the payment flow is dismissed. If the flow was completed successfully, the result will be `.success`, and you can call `confirm(from:completion:)` when you're ready to complete the payment. If it was not, the result will be `.failure` with an `Error` describing what happened; this will be `LinkPaymentController.Error.canceled` if the customer canceled the flow. +// @_spi(LinkOnly) public func present(from presentingViewController: UIViewController, completion: @escaping (Result) -> Void) { +// Task { +// do { +// try await present(from: presentingViewController) +// completion(.success(())) +// } catch { +// completion(.failure(error)) +// } +// } +// } +// +// /// Presents the Link payment flow, allowing your customer to pay with Link. +// /// The flow lets your customer log into or create a Link account, select a valid source of funds, and approve the usage of those funds to complete the purchase. The actual purchase will not occur until you call `confirm(from:)`. +// /// If this method returns successfully, you can call `confirm(from:)` when you're ready to complete the payment. +// /// - Note: Once `confirm(from:)` returns successfully, calling this method is an error, as payment/setup intents should not be reused. Until then, you may call this method as many times as is necessary. +// /// - Parameter presentingViewController: The view controller to present the payment flow from. +// /// - Throws: Either `LinkPaymentController.Error.canceled`, meaning the customer canceled the flow, or an error describing what went wrong. +// @MainActor +// @_spi(LinkOnly) public func present(from presentingViewController: UIViewController) async throws { +// let linkController: PayWithLinkViewController = try await withCheckedThrowingContinuation { [self] continuation in +// PaymentSheet.load(mode: mode, configuration: configuration) { result in +// switch result { +// case .success(let intent, _, let isLinkEnabled): +// guard isLinkEnabled else { +// continuation.resume(throwing: LinkPaymentController.Error.unavailable) +// return +// } +// self.intent = intent +// let linkController = PayWithLinkViewController( +// intent: intent, +// configuration: self.configuration, +// shouldOfferApplePay: false, +// shouldFinishOnClose: true, +// callToAction: .customWithLock(title: String.Localized.continue) +// ) +// continuation.resume(returning: linkController) +// case .failure(let error): +// continuation.resume(throwing: error) +// } +// } +// } +// +// linkController.payWithLinkDelegate = self +// linkController.modalPresentationStyle = UIDevice.current.userInterfaceIdiom == .pad +// ? .formSheet +// : .overFullScreen +// +// defer { linkController.dismiss(animated: true) } +// try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in +// payWithLinkContinuation = continuation +// presentingViewController.present(linkController, animated: true) +// } +// } +// +// /// Completes the Link payment or setup. +// /// - Note: Once `completion` is called with a `.completed` result, this `LinkPaymentController` instance should no longer be used, as payment/setup intents should not be reused. Other results indicate cancellation or failure, and do not invalidate the instance. +// /// - Parameter presentingViewController: The view controller used to present any view controllers required e.g. to authenticate the customer +// /// - Parameter completion: Called with the result of the payment after any presented view controllers are dismissed +// @_spi(LinkOnly) public func confirm(from presentingViewController: UIViewController, completion: @escaping (PaymentSheetResult) -> Void) { +// Task { +// do { +// try await confirm(from: presentingViewController) +// completion(.completed) +// } catch Error.canceled { +// completion(.canceled) +// } catch { +// completion(.failed(error: error)) +// } +// } +// } +// +// /// Completes the Link payment or setup. +// /// - Note: Once this method returns successfully, this `LinkPaymentController` instance should no longer be used, as payment/setup intents should not be reused. Thrown errors indicate cancellation or failure, and do not invalidate the instance. +// /// - Parameter presentingViewController: The view controller used to present any view controllers required e.g. to authenticate the customer +// /// - Throws: Either `LinkPaymentController.Error.canceled`, meaning the customer canceled the flow, or an error describing what went wrong. +// @MainActor +// @_spi(LinkOnly) public func confirm(from presentingViewController: UIViewController) async throws { +// if (intent == nil || paymentOption == nil) && LinkAccountService().hasSessionCookie { +// // If the customer has a Link cookie, `present` may not need to have been called - try to load here +// paymentOption = try await withCheckedThrowingContinuation { [self] continuation in +// PaymentSheet.load(mode: mode, configuration: configuration) { result in +// switch result { +// case .success(let intent, _, let isLinkEnabled): +// guard isLinkEnabled else { +// continuation.resume(throwing: Error.unavailable) +// return +// } +// self.intent = intent +// // TODO(bmelts): can we reliably determine the customer's previously used funding source (if any)? +// continuation.resume(returning: .link(option: .wallet)) +// case .failure(let error): +// continuation.resume(throwing: error) +// } +// } +// } +// } +// guard let intent = intent, let paymentOption = paymentOption else { +// assertionFailure("`confirm` should not be called without the customer authorizing Link. Make sure to call `present` first if your customer hasn't previously selected Link as a payment method.") +// throw PaymentSheetError.unknown(debugDescription: "confirm called without authorizing Link") +// } +// try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in +// PaymentSheet.confirm( +// configuration: configuration, +// authenticationContext: AuthenticationContext(presentingViewController: presentingViewController, appearance: .default), +// intent: intent, +// paymentOption: paymentOption, +// paymentHandler: STPPaymentHandler(apiClient: configuration.apiClient), +// isFlowController: false +// ) { result in +// switch result { +// case .completed: +// continuation.resume() +// case .canceled: +// continuation.resume(throwing: Error.canceled) +// case .failed(let error): +// continuation.resume(throwing: error) +// } +// } +// } +// } +// +// /// Deletes all Link authentication state associated with a customer. +// /// +// /// You must call this method when the user logs out from your app. +// /// This will ensure that any persisted authentication state, such as authentication cookies, is also cleared during logout. +// @_spi(LinkOnly) public static func resetCustomer() { +// PaymentSheet.resetCustomer() +// } +// +// /// Errors related to the Link payment flow +// /// +// /// Most errors do not originate from LinkPaymentController itself; instead, they come from the Stripe API or other SDK components +// @frozen @_spi(LinkOnly) public enum Error: Swift.Error { +// /// The customer canceled the flow they were in. +// case canceled +// /// Link is unavailable at this time. +// case unavailable +// } +// } +// +// @available(iOSApplicationExtension, unavailable) +// @available(macCatalystApplicationExtension, unavailable) +// extension LinkPaymentController: PayWithLinkViewControllerDelegate { +// func payWithLinkViewControllerDidConfirm(_ payWithLinkViewController: PayWithLinkViewController, intent: Intent, with paymentOption: PaymentOption, completion: @escaping (PaymentSheetResult) -> Void) { +// self.intent = intent +// self.paymentOption = paymentOption +// completion(.completed) +// } +// +// func payWithLinkViewControllerDidCancel(_ payWithLinkViewController: PayWithLinkViewController) { +// payWithLinkContinuation?.resume(throwing: Error.canceled) +// } +// +// func payWithLinkViewControllerDidFinish(_ payWithLinkViewController: PayWithLinkViewController, result: PaymentSheetResult) { +// switch result { +// case .canceled: +// payWithLinkContinuation?.resume(throwing: Error.canceled) +// case .failed(let error): +// payWithLinkContinuation?.resume(throwing: error) +// case .completed: +// payWithLinkContinuation?.resume() +// } +// } +// } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PayWithLinkController-New.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PayWithLinkController-New.swift new file mode 100644 index 00000000000..7877fc4e04e --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PayWithLinkController-New.swift @@ -0,0 +1,107 @@ +// +// PayWithNativeLinkController.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 7/18/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore +import UIKit + +/// Standalone Link controller +@available(iOSApplicationExtension, unavailable) +@available(macCatalystApplicationExtension, unavailable) +final class PayWithNativeLinkController { + + typealias CompletionBlock = PaymentSheetResultCompletionBlock + + private let paymentHandler: STPPaymentHandler + + private var completion: PaymentSheetResultCompletionBlock? + + private var selfRetainer: PayWithNativeLinkController? + + let intent: Intent + let elementsSession: STPElementsSession + let configuration: PaymentSheet.Configuration + + init(intent: Intent, elementsSession: STPElementsSession, configuration: PaymentSheet.Configuration) { + self.intent = intent + self.configuration = configuration + self.elementsSession = elementsSession + self.paymentHandler = .init(apiClient: configuration.apiClient) + } + + func present(completion: @escaping CompletionBlock) { + guard + let keyWindow = UIApplication.shared.stp_hackilyFumbleAroundUntilYouFindAKeyWindow(), + let presentedViewController = keyWindow.findTopMostPresentedViewController() + else { + assertionFailure("No key window with view controller found") + return + } + + present(from: presentedViewController, completion: completion) + } + + func present( + from presentingController: UIViewController, + completion: @escaping PaymentSheetResultCompletionBlock + ) { + // Similarly to `PKPaymentAuthorizationController`, `LinkController` should retain + // itself while presented. + self.selfRetainer = self + self.completion = completion + + let payWithLinkViewController = PayWithLinkViewController(intent: intent, elementsSession: elementsSession, configuration: configuration) + payWithLinkViewController.payWithLinkDelegate = self + payWithLinkViewController.modalPresentationStyle = UIDevice.current.userInterfaceIdiom == .pad + ? .formSheet + : .overFullScreen + + presentingController.present(payWithLinkViewController, animated: true) + } + +} + +@available(iOSApplicationExtension, unavailable) +@available(macCatalystApplicationExtension, unavailable) +extension PayWithNativeLinkController: PayWithLinkViewControllerDelegate { + + func payWithLinkViewControllerDidConfirm( + _ payWithLinkViewController: PayWithLinkViewController, + intent: Intent, + with paymentOption: PaymentOption, + completion: @escaping (PaymentSheetResult, STPAnalyticsClient.DeferredIntentConfirmationType?) -> Void + ) { + PaymentSheet.confirm( + configuration: configuration, + authenticationContext: payWithLinkViewController, + intent: intent, +// TODO(link): Add elements session + elementsSession: STPElementsSession.makeBackupElementsSession(allResponseFields: [:], paymentMethodTypes: []), + paymentOption: paymentOption, + paymentHandler: paymentHandler, + isFlowController: false, + completion: completion + ) + } + + func payWithLinkViewControllerDidCancel(_ payWithLinkViewController: PayWithLinkViewController) { + payWithLinkViewController.dismiss(animated: true) +// TODO(link): Return deferred intent confirmation type, not .client + completion?(.canceled, .client) + selfRetainer = nil + } + + func payWithLinkViewControllerDidFinish(_ payWithLinkViewController: PayWithLinkViewController, result: PaymentSheetResult) { + payWithLinkViewController.dismiss(animated: true) +// TODO(link): Return deferred intent confirmation type, not .client + completion?(result, .client) + selfRetainer = nil + } + +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PaymentSheet-LinkConfirmOption.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PaymentSheet-LinkConfirmOption.swift index c18121c07ff..62ad5969a94 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PaymentSheet-LinkConfirmOption.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PaymentSheet-LinkConfirmOption.swift @@ -25,10 +25,22 @@ extension PaymentSheet { intentConfirmParams: IntentConfirmParams ) - /// Confirm with Payment Method. + /// Confirm with Payment Method. (Web fallback) case withPaymentMethod( paymentMethod: STPPaymentMethod ) + + /// Confirm intent with paymentDetails. + case withPaymentDetails( + account: PaymentSheetLinkAccount, + paymentDetails: ConsumerPaymentDetails + ) + + /// Confirm with Payment Method Params. + case withPaymentMethodParams( + account: PaymentSheetLinkAccount, + paymentMethodParams: STPPaymentMethodParams + ) } } @@ -45,6 +57,10 @@ extension PaymentSheet.LinkConfirmOption { return account case .withPaymentMethod: return nil + case .withPaymentDetails(let account, _): + return account + case .withPaymentMethodParams(let account, _): + return account } } @@ -56,6 +72,10 @@ extension PaymentSheet.LinkConfirmOption { return intentConfirmParams.paymentMethodParams.paymentSheetLabel case .withPaymentMethod(let paymentMethod): return paymentMethod.paymentSheetLabel + case .withPaymentDetails(_, let paymentDetails): + return paymentDetails.paymentSheetLabel + case .withPaymentMethodParams(_, let paymentMethodParams): + return paymentMethodParams.paymentSheetLabel } } @@ -67,6 +87,12 @@ extension PaymentSheet.LinkConfirmOption { return intentConfirmParams.paymentMethodParams.billingDetails case .withPaymentMethod(let paymentMethod): return paymentMethod.billingDetails + case .withPaymentDetails: +// TODO(link): Implement .billingDetails +// return paymentDetails.billingDetails + return nil + case .withPaymentMethodParams(_, let paymentMethodParams): + return paymentMethodParams.billingDetails } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift index a1db37b5304..c31df14bd30 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift @@ -453,6 +453,12 @@ extension PaymentSheet { } case .withPaymentMethod(let paymentMethod): confirmWithPaymentMethod(paymentMethod, nil, false) + case .withPaymentDetails(let linkAccount, let paymentDetails): +// TODO(link): Confirm the last two options should be "nil" and "false" + confirmWithPaymentDetails(linkAccount, paymentDetails, nil, false) + case .withPaymentMethodParams(let linkAccount, let paymentMethodParams): +// TODO(link): Confirm the last two options should be "nil" and "false" + createPaymentDetailsAndConfirm(linkAccount, paymentMethodParams, false) } case let .external(paymentMethod, billingDetails): guard let confirmHandler = configuration.externalPaymentMethodConfiguration?.externalPaymentMethodConfirmHandler else { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+PaymentMethodAvailability.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+PaymentMethodAvailability.swift index 55cdcbf7f0c..201648be540 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+PaymentMethodAvailability.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+PaymentMethodAvailability.swift @@ -55,6 +55,12 @@ extension PaymentSheet { } return !configuration.requiresBillingDetailCollection() } + + /// An unordered list of paymentMethodTypes that can be used with Link in PaymentSheet + /// - Note: This is a var because it depends on the authenticated Link user + /// + /// :nodoc: + internal static var supportedLinkPaymentMethods: [STPPaymentMethodType] = [] } // MARK: - PaymentMethodRequirementProvider diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift index 05e44e5513b..9761b9c71d6 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift @@ -187,6 +187,9 @@ extension PaymentSheet { /// - Note: If you omit payment methods from this list, they’ll be automatically ordered by Stripe after the ones you provide. Invalid payment methods are ignored. public var paymentMethodOrder: [String]? + // MARK: Internal + internal var linkPaymentMethodsOnly: Bool = false + /// This is an experimental feature that may be removed at any time. /// If true (the default), the customer can delete all saved payment methods. /// If false, the customer can't delete if they only have one saved payment method remaining. diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift index 86e2d786079..72e414c81d4 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift @@ -463,6 +463,16 @@ class SavedPaymentOptionsViewController: UIViewController { let defaultSelectedIndex = viewModels.firstIndex(where: { $0 == defaultPaymentMethod }) ?? defaultIndex return (defaultSelectedIndex, viewModels) } + + func selectLink() { + guard configuration.showLink else { + return + } + + CustomerPaymentOption.setDefaultPaymentMethod(.link, forCustomer: configuration.customerID) + selectedViewModelIndex = viewModels.firstIndex(where: { $0 == .link }) + collectionView.selectItem(at: selectedIndexPath, animated: false, scrollPosition: .centeredHorizontally) + } } // MARK: - UICollectionView diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift index 18e5746461e..5f88dd8dce4 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift @@ -194,6 +194,10 @@ class PaymentSheetViewController: UIViewController, PaymentSheetViewControllerPr self.view.backgroundColor = configuration.appearance.colors.background } + func selectLink() { + savedPaymentOptionsViewController.selectLink() + } + // MARK: UIViewController Methods override func viewDidLoad() { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/ConfirmButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/ConfirmButton.swift index 96d54545148..d77adb4767a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/ConfirmButton.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/ConfirmButton.swift @@ -37,6 +37,7 @@ class ConfirmButton: UIView { } enum CallToActionType { case pay(amount: Int, currency: String) + case add(paymentMethodType: PaymentSheet.PaymentMethodType) case `continue` case setup case custom(title: String) @@ -233,7 +234,7 @@ class ConfirmButton: UIView { titleLabel.font = font } } - + /// Background color for the `.disabled` state. var disabledBackgroundColor: UIColor { return appearance.primaryButton.disabledBackgroundColor ?? appearance.primaryButton.backgroundColor ?? appearance.colors.primary @@ -309,6 +310,12 @@ class ConfirmButton: UIView { lazy var spinner: CheckProgressView = { return CheckProgressView(frame: CGRect(origin: .zero, size: spinnerSize)) }() + lazy var addIcon: UIImageView = { + let image = Image.icon_plus.makeImage(template: true) + let icon = UIImageView(image: image) + icon.setContentCompressionResistancePriority(.required, for: .horizontal) + return icon + }() var foregroundColor: UIColor = .white { didSet { foregroundColorDidChange() @@ -316,7 +323,6 @@ class ConfirmButton: UIView { } var overriddenForegroundColor: UIColor? - init(appearance: PaymentSheet.Appearance = .default) { self.appearance = appearance @@ -328,7 +334,7 @@ class ConfirmButton: UIView { isAccessibilityElement = true // Add views - let views = ["titleLabel": titleLabel, "lockIcon": lockIcon, "spinnyView": spinner] + let views = ["titleLabel": titleLabel, "lockIcon": lockIcon, "spinnyView": spinner, "addIcon": addIcon] views.values.forEach { $0.translatesAutoresizingMaskIntoConstraints = false addSubview($0) @@ -341,6 +347,10 @@ class ConfirmButton: UIView { equalTo: centerXAnchor) titleLabelCenterXConstraint.priority = .defaultLow NSLayoutConstraint.activate([ + // Add icon + addIcon.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + addIcon.centerYAnchor.constraint(equalTo: centerYAnchor), + // Label titleLabelCenterXConstraint, titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), @@ -385,6 +395,12 @@ class ConfirmButton: UIView { switch status { case .enabled, .disabled, .spinnerWithInteractionDisabled: switch callToAction { + case .add(let paymentMethodType): + if paymentMethodType == .instantDebits { + return STPLocalizedString("Add bank account", "Button prompt to add a bank account as a payment method.") + } else { + return String.Localized.continue + } case .continue: return String.Localized.continue case let .pay(amount, currency): @@ -417,14 +433,18 @@ class ConfirmButton: UIView { // Show/hide lock and add icons switch callToAction { - case .continue: + case .add(let paymentMethodType): lockIcon.isHidden = true - case .custom: + addIcon.isHidden = paymentMethodType != .instantDebits + case .custom, .continue: lockIcon.isHidden = true + addIcon.isHidden = true case .customWithLock: lockIcon.isHidden = false + addIcon.isHidden = true case .pay, .setup: lockIcon.isHidden = false + addIcon.isHidden = true } // Update accessibility information @@ -486,9 +506,11 @@ class ConfirmButton: UIView { switch status { case .disabled, .enabled: self.lockIcon.alpha = self.titleLabel.alpha + self.addIcon.alpha = self.titleLabel.alpha self.spinner.alpha = 0 case .processing, .spinnerWithInteractionDisabled: self.lockIcon.alpha = 0 + self.addIcon.alpha = 0 self.spinner.alpha = 1 self.spinnerCenteredToLockConstraint.isActive = true self.spinnerCenteredConstraint.isActive = false @@ -534,7 +556,7 @@ class ConfirmButton: UIView { if status == .disabled, let disabledTextColor = appearance.primaryButton.disabledTextColor { return disabledTextColor } - + // Use successTextColor if in succeeded state and provided, otherwise fallback to foreground color if status == .succeeded, let successTextColor = appearance.primaryButton.successTextColor { return successTextColor diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/PayWithLinkButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/PayWithLinkButton.swift index 2b48e68c148..3408ce78e09 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/PayWithLinkButton.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/PayWithLinkButton.swift @@ -31,11 +31,13 @@ final class PayWithLinkButton: UIControl { fileprivate struct LinkAccountStub: PaymentSheetLinkAccountInfoProtocol { let email: String + let redactedPhoneNumber: String? let isRegistered: Bool + let isLoggedIn: Bool } /// Link account of the current user. - var linkAccount: PaymentSheetLinkAccountInfoProtocol? = LinkAccountStub(email: "", isRegistered: false) { + var linkAccount: PaymentSheetLinkAccountInfoProtocol? = LinkAccountContext.shared.account { didSet { updateUI() } @@ -464,7 +466,9 @@ struct UIViewPreview: UIViewRepresentable { private func makeAccountStub(email: String, isRegistered: Bool, lastPM: LinkPMDisplayDetails?) -> PayWithLinkButton.LinkAccountStub { return PayWithLinkButton.LinkAccountStub( email: email, - isRegistered: isRegistered + redactedPhoneNumber: nil, + isRegistered: isRegistered, + isLoggedIn: false ) } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/ButtonLinkSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/ButtonLinkSnapshotTests.swift new file mode 100644 index 00000000000..949626c18e7 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/ButtonLinkSnapshotTests.swift @@ -0,0 +1,72 @@ +// +// ButtonLinkSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 2/14/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripeUICore + +class ButtonLinkSnapshotTests: STPSnapshotTestCase { + + func testPrimary() { + let sut = makeSUT(configuration: .linkPrimary(), title: "Primary Button") + verify(sut) + } + + func testSecondary() { + let sut = makeSUT(configuration: .linkSecondary(), title: "Secondary Button") + verify(sut) + } + + func testBordered() { + let sut = makeSUT(configuration: .linkBordered(), title: "Bordered Button") + verify(sut) + } + + func testPlain() { + let sut = makeSUT(configuration: .linkPlain(), title: "Plain Button") + verify(sut) + } + + func verify( + _ sut: Button, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + let size = sut.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + sut.bounds = CGRect(origin: .zero, size: size) + STPSnapshotVerifyView(sut, file: file, line: line) + + sut.isHighlighted = true + STPSnapshotVerifyView(sut, identifier: "Highlighted", file: file, line: line) + + sut.isHighlighted = false + sut.isEnabled = false + STPSnapshotVerifyView(sut, identifier: "Disabled", file: file, line: line) + + sut.isHighlighted = false + sut.isEnabled = true + sut.isLoading = true + STPSnapshotVerifyView(sut, identifier: "Loading", file: file, line: line) + } + +} + +extension ButtonLinkSnapshotTests { + + func makeSUT( + configuration: Button.Configuration, + title: String + ) -> Button { + return Button(configuration: configuration, title: title) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkBadgeViewSnapshotTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkBadgeViewSnapshotTest.swift new file mode 100644 index 00000000000..8aaef64aa83 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkBadgeViewSnapshotTest.swift @@ -0,0 +1,53 @@ +// +// LinkBadgeViewSnapshotTest.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 4/29/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkBadgeViewSnapshotTest: STPSnapshotTestCase { + + func testNeutral() { + verify( + LinkBadgeView( + type: .neutral, + text: "Neutral message" + ) + ) + } + + func testError() { + verify( + LinkBadgeView( + type: .error, + text: "Error message" + ) + ) + } + + func verify( + _ sut: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + let size = sut.systemLayoutSizeFitting( + UIView.layoutFittingCompressedSize, + withHorizontalFittingPriority: .fittingSizeLevel, + verticalFittingPriority: .fittingSizeLevel + ) + + sut.bounds = CGRect(origin: .zero, size: size) + STPSnapshotVerifyView(sut, identifier: identifier, file: file, line: line) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkCardEditElementSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkCardEditElementSnapshotTests.swift new file mode 100644 index 00000000000..ded3d7b0db0 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkCardEditElementSnapshotTests.swift @@ -0,0 +1,73 @@ +// +// LinkCardEditElementSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 10/3/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@testable@_spi(STP) import StripeCoreTestUtils +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripeUICore + +final class LinkCardEditElementSnapshotTests: STPSnapshotTestCase { + + override func setUp() { + super.setUp() + // self.recordMode = true + + // `LinkCardEditElement` depends on `AddressSectionElement`, which requires + // address specs to be loaded in memory. + let expectation = expectation(description: "Load address specs") + AddressSpecProvider.shared.loadAddressSpecs { + expectation.fulfill() + } + + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testDefault() { + let sut = makeSUT(isDefault: true) + verify(sut) + } + + func testNonDefault() { + let sut = makeSUT(isDefault: false) + verify(sut) + } + + func verify( + _ element: LinkCardEditElement, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + element.view.autosizeHeight(width: 340) + STPSnapshotVerifyView(element.view, identifier: identifier, file: file, line: line) + } + +} + +extension LinkCardEditElementSnapshotTests { + + func makeSUT(isDefault: Bool) -> LinkCardEditElement { + let paymentMethod = ConsumerPaymentDetails( + stripeID: "1", + details: .card( + card: .init( + expiryYear: 2032, + expiryMonth: 1, + brand: "visa", + last4: "4242", + checks: nil + ) + ), + isDefault: isDefault + ) + + return LinkCardEditElement(paymentMethod: paymentMethod, configuration: .init()) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkInlineSignupElementSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkInlineSignupElementSnapshotTests.swift new file mode 100644 index 00000000000..7e45a4284be --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkInlineSignupElementSnapshotTests.swift @@ -0,0 +1,117 @@ +// +// LinkInlineSignupElementSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 1/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCoreTestUtils +@_spi(STP) import StripeUICore +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkInlineSignupElementSnapshotTests: STPSnapshotTestCase { + + func testDefaultState() { + let sut = makeSUT() + verify(sut) + } + + func testExpandedState() { + let sut = makeSUT(saveCheckboxChecked: true, emailAddress: "user@example.com") + verify(sut) + } + + func testExpandedState_nonUS() { + let sut = makeSUT( + saveCheckboxChecked: true, + emailAddress: "user@example.com", + country: "CA" + ) + verify(sut) + } + + func testExpandedState_nonUS_preFilled() { + let sut = makeSUT( + saveCheckboxChecked: true, + emailAddress: "user@example.com", + country: "CA", + preFillName: "Jane Diaz", + preFillPhone: "+13105551234" + ) + verify(sut) + } + + func verify( + _ element: LinkInlineSignupElement, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + element.view.autosizeHeight(width: 340) + STPSnapshotVerifyView(element.view, identifier: identifier, file: file, line: line) + } + +} + +extension LinkInlineSignupElementSnapshotTests { + + struct MockAccountService: LinkAccountServiceProtocol { + func lookupAccount( + withEmail email: String?, + completion: @escaping (Result) -> Void + ) { + completion( + .success( + PaymentSheetLinkAccount( + email: "user@example.com", + session: nil, + publishableKey: nil + ) + ) + ) + } + + func hasEmailLoggedOut(email: String) -> Bool { + // TODO(porter): Determine if we want to implement this in tests + return false + } + } + + func makeSUT( + saveCheckboxChecked: Bool = false, + emailAddress: String? = nil, + country: String = "US", + preFillName: String? = nil, + preFillPhone: String? = nil + ) -> LinkInlineSignupElement { + var configuration = PaymentSheet.Configuration() + configuration.merchantDisplayName = "[Merchant]" + configuration.defaultBillingDetails.name = preFillName + configuration.defaultBillingDetails.phone = preFillPhone + + let viewModel = LinkInlineSignupViewModel( + configuration: configuration, + showCheckbox: true, + accountService: MockAccountService(), + country: country + ) + + viewModel.saveCheckboxChecked = saveCheckboxChecked + viewModel.emailAddress = emailAddress + + if emailAddress != nil { + // Wait for account to load + let expectation = notNullExpectation(for: viewModel, keyPath: \.linkAccount) + wait(for: [expectation], timeout: 10) + } + + return .init(viewModel: viewModel) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkInstantDebitMandateViewSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkInstantDebitMandateViewSnapshotTests.swift new file mode 100644 index 00000000000..88d2bbeb569 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkInstantDebitMandateViewSnapshotTests.swift @@ -0,0 +1,72 @@ +// +// LinkInstantDebitMandateViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 2/17/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +@testable@_spi(STP) import StripeUICore + +class LinkInstantDebitMandateViewSnapshotTests: STPSnapshotTestCase { + + func testDefault() { + let sut = makeSUT() + verify(sut) + } + + func testLocalization() { + performLocalizedSnapshotTest(forLanguage: "de") + performLocalizedSnapshotTest(forLanguage: "es") + performLocalizedSnapshotTest(forLanguage: "el-GR") + performLocalizedSnapshotTest(forLanguage: "it") + performLocalizedSnapshotTest(forLanguage: "ja") + performLocalizedSnapshotTest(forLanguage: "ko") + performLocalizedSnapshotTest(forLanguage: "zh-Hans") + } + +} + +// MARK: - Helpers + +extension LinkInstantDebitMandateViewSnapshotTests { + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 250) + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } + + func performLocalizedSnapshotTest( + forLanguage language: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + STPLocalizationUtils.overrideLanguage(to: language) + let sut = makeSUT() + STPLocalizationUtils.overrideLanguage(to: nil) + verify(sut, identifier: language, file: file, line: line) + } + +} + +// MARK: - Factory + +extension LinkInstantDebitMandateViewSnapshotTests { + + func makeSUT() -> LinkInstantDebitMandateView { + return LinkInstantDebitMandateView() + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkNavigationBarSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkNavigationBarSnapshotTests.swift new file mode 100644 index 00000000000..23709ecff5c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkNavigationBarSnapshotTests.swift @@ -0,0 +1,83 @@ +// +// LinkNavigationBarSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 4/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkNavigationBarSnapshotTests: STPSnapshotTestCase { + + func testDefault() { + let sut = makeSUT() + verify(sut) + + sut.showBackButton = true + verify(sut, identifier: "BackButton") + } + + func testWithEmailAddress() { + let sut = makeSUT() + sut.linkAccount = makeAccountStub(email: "user@example.com") + verify(sut) + + sut.showBackButton = true + verify(sut, identifier: "BackButton") + } + + func testWithLongEmailAddress() { + let sut = makeSUT() + sut.linkAccount = makeAccountStub(email: "a.very.very.long.customer.name@example.com") + verify(sut) + + sut.showBackButton = true + verify(sut, identifier: "BackButton") + } + + func verify( + _ sut: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + let size = sut.systemLayoutSizeFitting( + CGSize(width: 375, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + + sut.bounds = CGRect(origin: .zero, size: size) + STPSnapshotVerifyView(sut, identifier: identifier, file: file, line: line) + } + +} + +extension LinkNavigationBarSnapshotTests { + fileprivate struct LinkAccountStub: PaymentSheetLinkAccountInfoProtocol { + let email: String + let redactedPhoneNumber: String? + let isRegistered: Bool + let isLoggedIn: Bool + } + + fileprivate func makeAccountStub(email: String) -> LinkAccountStub { + return LinkAccountStub( + email: email, + redactedPhoneNumber: "+1********55", + isRegistered: true, + isLoggedIn: true + ) + } + + fileprivate func makeSUT() -> LinkNavigationBar { + return LinkNavigationBar() + } +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkNoticeViewSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkNoticeViewSnapshotTests.swift new file mode 100644 index 00000000000..6175c1aa68a --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkNoticeViewSnapshotTests.swift @@ -0,0 +1,45 @@ +// +// LinkNoticeViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 3/31/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkNoticeViewSnapshotTests: STPSnapshotTestCase { + + func testError() { + verify( + LinkNoticeView( + type: .error, + text: + "This card has expired. Update it to keep using it or use a different payment method." + ) + ) + } + + func verify( + _ sut: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + let size = sut.systemLayoutSizeFitting( + CGSize(width: 340, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + + sut.bounds = CGRect(origin: .zero, size: size) + STPSnapshotVerifyView(sut, identifier: identifier, file: file, line: line) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift new file mode 100644 index 00000000000..240a5ffb90c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift @@ -0,0 +1,101 @@ +// +// LinkPaymentMethodPickerSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 11/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkPaymentMethodPickerSnapshotTests: STPSnapshotTestCase { + + func testNormal() { + let mockDataSource = MockDataSource() + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.layoutSubviews() + + verify(picker, identifier: "First Option") + + picker.selectedIndex = 1 + verify(picker, identifier: "Second Option") + } + + func testExpanded() { + let mockDataSource = MockDataSource() + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.layoutSubviews() + picker.setExpanded(true, animated: false) + + verify(picker) + } + + func testUnsupportedBankAccount() { + let mockDataSource = MockDataSource() + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.supportedPaymentMethodTypes = [.card] + picker.layoutSubviews() + picker.setExpanded(true, animated: false) + + verify(picker) + } + + func testEmpty() { + let mockDataSource = MockDataSource(empty: true) + + let picker = LinkPaymentMethodPicker() + picker.dataSource = mockDataSource + picker.layoutSubviews() + + verify(picker) + } + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 335) + view.backgroundColor = .white + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } + +} + +extension LinkPaymentMethodPickerSnapshotTests { + + fileprivate final class MockDataSource: LinkPaymentMethodPickerDataSource { + let paymentMethods: [ConsumerPaymentDetails] + + init( + empty: Bool = false + ) { + self.paymentMethods = empty ? [] : LinkStubs.paymentMethods() + } + + func numberOfPaymentMethods(in picker: LinkPaymentMethodPicker) -> Int { + return paymentMethods.count + } + + func paymentPicker( + _ picker: LinkPaymentMethodPicker, + paymentMethodAt index: Int + ) -> ConsumerPaymentDetails { + return paymentMethods[index] + } + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkToastSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkToastSnapshotTests.swift new file mode 100644 index 00000000000..e3163848d86 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkToastSnapshotTests.swift @@ -0,0 +1,35 @@ +// +// LinkToastSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 12/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkToastSnapshotTests: STPSnapshotTestCase { + + func testSuccess() { + let toast = LinkToast(type: .success, text: "Success message!") + verify(toast) + } + + func verify( + _ toast: LinkToast, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + let size = toast.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + toast.bounds = CGRect(origin: .zero, size: size) + STPSnapshotVerifyView(toast, identifier: identifier, file: file, line: line) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkVerificationViewSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkVerificationViewSnapshotTests.swift new file mode 100644 index 00000000000..2763e508613 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkVerificationViewSnapshotTests.swift @@ -0,0 +1,77 @@ +// +// LinkVerificationViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 12/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkVerificationViewSnapshotTests: STPSnapshotTestCase { + + func testModal() { + let sut = makeSUT(mode: .modal) + verify(sut) + } + + func testModalWithErrorMessage() { + let sut = makeSUT(mode: .modal) + sut.errorMessage = "The provided verification code has expired." + verify(sut) + } + + func testInlineLogin() { + let sut = makeSUT(mode: .inlineLogin) + verify(sut) + } + + func testEmbedded() { + let sut = makeSUT(mode: .embedded) + verify(sut) + } + + func verify( + _ view: LinkVerificationView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 340) + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } + +} + +extension LinkVerificationViewSnapshotTests { + + struct LinkAccountStub: PaymentSheetLinkAccountInfoProtocol { + let email: String + let redactedPhoneNumber: String? + let isRegistered: Bool + let isLoggedIn: Bool + } + + func makeSUT(mode: LinkVerificationView.Mode) -> LinkVerificationView { + let sut = LinkVerificationView( + mode: mode, + linkAccount: LinkAccountStub( + email: "user@example.com", + redactedPhoneNumber: "+1********55", + isRegistered: true, + isLoggedIn: false + ) + ) + + sut.tintColor = .linkBrand + + return sut + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/PayWithLinkViewController-WalletViewModelTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/PayWithLinkViewController-WalletViewModelTests.swift new file mode 100644 index 00000000000..3b12f5c2551 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/PayWithLinkViewController-WalletViewModelTests.swift @@ -0,0 +1,148 @@ +// +// PayWithLinkViewController-WalletViewModelTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 3/31/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +import StripePaymentsTestUtils +@testable@_spi(STP) import StripePaymentsUI + +class PayWithLinkViewController_WalletViewModelTests: XCTestCase { + + func test_shouldRecollectCardCVC() throws { + let sut = try makeSUT() + + // Card with passing CVC checks + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.card + XCTAssertFalse(sut.shouldRecollectCardCVC) + + // Card with failing CVC checks + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.cardWithFailingChecks + XCTAssertTrue( + sut.shouldRecollectCardCVC, + "Should recollect CVC when CVC checks are failing" + ) + + // Expired card + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.expiredCard + XCTAssertTrue(sut.shouldRecollectCardCVC, "Should recollect CVC when card has expired") + + // Bank account (CVC not supported) + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.bankAccount + XCTAssertFalse(sut.shouldRecollectCardCVC) + } + + func test_shouldRecollectCardExpiry() throws { + let sut = try makeSUT() + + // Non-expired card + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.card + XCTAssertFalse(sut.shouldRecollectCardExpiryDate) + + // Expired card + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.expiredCard + XCTAssertTrue( + sut.shouldRecollectCardExpiryDate, + "Should recollect new expiry date when card has expired" + ) + + // Bank account (CVC not supported) + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.bankAccount + XCTAssertFalse(sut.shouldRecollectCardCVC) + } + + func test_shouldShowInstantDebitMandate() throws { + let sut = try makeSUT() + + // Card + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.card + XCTAssertFalse(sut.shouldShowInstantDebitMandate) + + // Bank account + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.bankAccount + XCTAssertTrue(sut.shouldShowInstantDebitMandate) + } + + func test_confirmButtonStatus_shouldHandleNoSelection() throws { + let sut = try makeSUT() + + // No selection + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.notExisting + XCTAssertEqual( + sut.confirmButtonStatus, + .disabled, + "Button should be disabled when no payment method is selected" + ) + + // Selection + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.card + XCTAssertEqual(sut.confirmButtonStatus, .enabled) + } + + func test_confirmButtonStatus_shouldHandleCVCRecollectionRequirements() throws { + let sut = try makeSUT() + + sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.cardWithFailingChecks + XCTAssertEqual( + sut.confirmButtonStatus, + .disabled, + "Button should be disabled when no CVC is provided and a card with failing CVC checks is selected" + ) + + // Provide a CVC + sut.cvc = "123" + XCTAssertEqual(sut.confirmButtonStatus, .enabled) + } +} + +extension PayWithLinkViewController_WalletViewModelTests { + + func makeSUT() throws -> PayWithLinkViewController.WalletViewModel { + // Link settings don't live in the PaymentIntent object itself, but in the /elements/sessions API response + // So we construct a minimal response (see STPPaymentIntentTest.testDecodedObjectFromAPIResponseMapping) to parse them + let paymentIntentJson = try XCTUnwrap(STPTestUtils.jsonNamed(STPTestJSONPaymentIntent)) + let orderedPaymentJson = ["card", "link"] + let paymentIntentResponse = [ + "payment_intent": paymentIntentJson, + "ordered_payment_method_types": orderedPaymentJson, + ] as [String: Any] + let linkSettingsJson = ["link_funding_sources": ["CARD"]] + let response = [ + "payment_method_preference": paymentIntentResponse, + "link_settings": linkSettingsJson, + "session_id": "abc123", + ] as [String: Any] + let elementsSession = try XCTUnwrap( + STPElementsSession.decodedObject(fromAPIResponse: response) + ) + let paymentIntentJSON = elementsSession.allResponseFields[jsonDict: "payment_method_preference"]?[jsonDict: "payment_intent"] + let paymentIntent = STPPaymentIntent.decodedObject(fromAPIResponse: paymentIntentJSON)! + + return PayWithLinkViewController.WalletViewModel( + // TODO(ramont): Fully mock `PaymentSheetLinkAccount and remove this. + linkAccount: .init( + email: "user@example.com", + session: LinkStubs.consumerSession(), + publishableKey: nil + ), + context: .init( + intent: .paymentIntent(paymentIntent), + elementsSession: elementsSession, + configuration: .init(), + shouldOfferApplePay: false, + shouldFinishOnClose: false, + callToAction: nil + ), + paymentMethods: LinkStubs.paymentMethods() + ) + } + +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift index affbe8ce890..4dcec786c77 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift @@ -30,16 +30,42 @@ extension LinkStubs { static func paymentMethods() -> [ConsumerPaymentDetails] { return [ ConsumerPaymentDetails( - stripeID: "1" + stripeID: "1", + details: .card(card: .init( + expiryYear: 30, + expiryMonth: 10, + brand: "visa", + last4: "1234", + checks: nil) + ), + isDefault: true ), ConsumerPaymentDetails( - stripeID: "2" + stripeID: "2", + details: .card(card: .init( + expiryYear: 30, + expiryMonth: 10, + brand: "mastercard", + last4: "4321", + checks: .init(cvcCheck: .fail)) + ), + isDefault: false ), ConsumerPaymentDetails( - stripeID: "3" + stripeID: "3", + details: .bankAccount(bankAccount: .init(iconCode: nil, name: "test", last4: "1234")), + isDefault: false ), ConsumerPaymentDetails( - stripeID: "4" + stripeID: "4", + details: .card(card: .init( + expiryYear: 20, + expiryMonth: 10, + brand: "discover", + last4: "1111", + checks: nil) + ), + isDefault: false ), ] } @@ -48,7 +74,9 @@ extension LinkStubs { return ConsumerSession( clientSecret: "client_secret", emailAddress: "user@example.com", - verificationSessions: [] + redactedPhoneNumber: "+1********55", + verificationSessions: [], + supportedPaymentDetailsTypes: [.card, .bankAccount] ) } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift index 5808f1107ac..9dbcba74315 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift @@ -53,7 +53,9 @@ extension PaymentSheetLinkAccountTests { func makePaymentDetailsStub() -> ConsumerPaymentDetails { return ConsumerPaymentDetails( - stripeID: "1" + stripeID: "1", + details: .card(card: .init(expiryYear: 30, expiryMonth: 10, brand: "visa", last4: "1234", checks: nil)), + isDefault: false ) } diff --git a/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Views/CardBrandView.swift b/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Views/CardBrandView.swift index 5344b75c956..d94c880aaba 100644 --- a/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Views/CardBrandView.swift +++ b/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Views/CardBrandView.swift @@ -47,7 +47,7 @@ import UIKit private var centeringPadding: UIEdgeInsets { return UIEdgeInsets( top: 0, - left: 0, + left: centerHorizontally ? Self.iconPadding.right : 0, bottom: Self.iconPadding.top, right: 0 ) @@ -71,6 +71,9 @@ import UIKit /// If `true`, the view will display the CVC hint icon instead of the card brand image. let showCVC: Bool + /// If `true`, will center the card brand icon horizontally in the containing view + let centerHorizontally: Bool + /// If `true`, show a CBC indicator arrow var isShowingCBCIndicator: Bool = false { didSet { @@ -125,9 +128,11 @@ import UIKit /// Creates and returns an initialized card brand view. /// - Parameter showCVC: Whether or not to show the CVC hint icon instead of the card brand image. @_spi(STP) public init( - showCVC: Bool = false + showCVC: Bool = false, + centerHorizontally: Bool = false ) { self.showCVC = showCVC + self.centerHorizontally = centerHorizontally super.init(frame: .zero) addSubview(imageView) diff --git a/StripeUICore/StripeUICoreTests/Snapshot/Controls/ButtonSnapshotTest.swift b/StripeUICore/StripeUICoreTests/Snapshot/Controls/ButtonSnapshotTest.swift index accbce379ab..e0db6c7c2ef 100644 --- a/StripeUICore/StripeUICoreTests/Snapshot/Controls/ButtonSnapshotTest.swift +++ b/StripeUICore/StripeUICoreTests/Snapshot/Controls/ButtonSnapshotTest.swift @@ -6,9 +6,9 @@ // Copyright © 2021 Stripe, Inc. All rights reserved. // -import iOSSnapshotTestCase import StripeCoreTestUtils @_spi(STP) import StripeUICore +import UIKit final class ButtonSnapshotTest: STPSnapshotTestCase { diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered@3x.png new file mode 100644 index 00000000000..2aed510d18f Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Disabled@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Disabled@3x.png new file mode 100644 index 00000000000..28ff08090a6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Disabled@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Highlighted@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Highlighted@3x.png new file mode 100644 index 00000000000..e25f96205e8 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Highlighted@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Loading@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Loading@3x.png new file mode 100644 index 00000000000..2c703c7de11 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testBordered_Loading@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain@3x.png new file mode 100644 index 00000000000..37e46b260e4 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Disabled@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Disabled@3x.png new file mode 100644 index 00000000000..10479eb9cdc Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Disabled@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Highlighted@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Highlighted@3x.png new file mode 100644 index 00000000000..3bc2e79e77d Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Highlighted@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Loading@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Loading@3x.png new file mode 100644 index 00000000000..392ffa96d28 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPlain_Loading@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary@3x.png new file mode 100644 index 00000000000..bcb28a3fe40 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Disabled@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Disabled@3x.png new file mode 100644 index 00000000000..3d262813aba Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Disabled@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Highlighted@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Highlighted@3x.png new file mode 100644 index 00000000000..836d566c29d Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Highlighted@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Loading@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Loading@3x.png new file mode 100644 index 00000000000..895535324c9 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testPrimary_Loading@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary@3x.png new file mode 100644 index 00000000000..bac8586566b Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Disabled@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Disabled@3x.png new file mode 100644 index 00000000000..a25f6bb41c7 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Disabled@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Highlighted@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Highlighted@3x.png new file mode 100644 index 00000000000..543b301dfe6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Highlighted@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Loading@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Loading@3x.png new file mode 100644 index 00000000000..f2071e2f22d Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.ButtonLinkSnapshotTests/testSecondary_Loading@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkBadgeViewSnapshotTest/testError@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkBadgeViewSnapshotTest/testError@3x.png new file mode 100644 index 00000000000..2dfd6ab35cf Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkBadgeViewSnapshotTest/testError@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkBadgeViewSnapshotTest/testNeutral@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkBadgeViewSnapshotTest/testNeutral@3x.png new file mode 100644 index 00000000000..261e6537d5d Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkBadgeViewSnapshotTest/testNeutral@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testDefault@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testDefault@3x.png new file mode 100644 index 00000000000..23d7572fc60 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testDefault@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testNonDefault@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testNonDefault@3x.png new file mode 100644 index 00000000000..0b009bf0b14 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkCardEditElementSnapshotTests/testNonDefault@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testDefaultState@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testDefaultState@3x.png new file mode 100644 index 00000000000..ad928c3a21a Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testDefaultState@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState@3x.png new file mode 100644 index 00000000000..ccf5ec3d0a5 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState_nonUS@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState_nonUS@3x.png new file mode 100644 index 00000000000..7d1cfd9eba7 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState_nonUS@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState_nonUS_preFilled@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState_nonUS_preFilled@3x.png new file mode 100644 index 00000000000..d399a2c9d03 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInlineSignupElementSnapshotTests/testExpandedState_nonUS_preFilled@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testDefault@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testDefault@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testDefault@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_de@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_de@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_de@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_el_GR@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_el_GR@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_el_GR@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_es@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_es@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_es@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_it@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_it@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_it@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_ja@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_ja@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_ja@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_ko@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_ko@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_ko@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_zh_Hans@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_zh_Hans@3x.png new file mode 100644 index 00000000000..2874298e2f6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkInstantDebitMandateViewSnapshotTests/testLocalization_zh_Hans@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testDefault@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testDefault@3x.png new file mode 100644 index 00000000000..66dcc162102 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testDefault@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testDefault_BackButton@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testDefault_BackButton@3x.png new file mode 100644 index 00000000000..374006515a6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testDefault_BackButton@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithEmailAddress@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithEmailAddress@3x.png new file mode 100644 index 00000000000..6d290297d31 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithEmailAddress@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithEmailAddress_BackButton@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithEmailAddress_BackButton@3x.png new file mode 100644 index 00000000000..374006515a6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithEmailAddress_BackButton@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithLongEmailAddress@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithLongEmailAddress@3x.png new file mode 100644 index 00000000000..95e90892298 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithLongEmailAddress@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithLongEmailAddress_BackButton@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithLongEmailAddress_BackButton@3x.png new file mode 100644 index 00000000000..374006515a6 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNavigationBarSnapshotTests/testWithLongEmailAddress_BackButton@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNoticeViewSnapshotTests/testError@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNoticeViewSnapshotTests/testError@3x.png new file mode 100644 index 00000000000..78fb53787f1 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkNoticeViewSnapshotTests/testError@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testEmpty@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testEmpty@3x.png new file mode 100644 index 00000000000..074be8faff1 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testEmpty@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testExpanded@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testExpanded@3x.png new file mode 100644 index 00000000000..9703d37c31b Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testExpanded@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_First_Option@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_First_Option@3x.png new file mode 100644 index 00000000000..31861aa3354 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_First_Option@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_Second_Option@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_Second_Option@3x.png new file mode 100644 index 00000000000..50b5afa1ed5 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testNormal_Second_Option@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testUnsupportedBankAccount@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testUnsupportedBankAccount@3x.png new file mode 100644 index 00000000000..4498759c7c9 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkPaymentMethodPickerSnapshotTests/testUnsupportedBankAccount@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkToastSnapshotTests/testSuccess@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkToastSnapshotTests/testSuccess@3x.png new file mode 100644 index 00000000000..3e85d4ac97b Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkToastSnapshotTests/testSuccess@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testEmbedded@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testEmbedded@3x.png new file mode 100644 index 00000000000..73994a01b5c Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testEmbedded@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testInlineLogin@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testInlineLogin@3x.png new file mode 100644 index 00000000000..b963f318ac2 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testInlineLogin@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModal@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModal@3x.png new file mode 100644 index 00000000000..13f36970a73 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModal@3x.png differ diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModalWithErrorMessage@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModalWithErrorMessage@3x.png new file mode 100644 index 00000000000..02cca12519e Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.LinkVerificationViewSnapshotTests/testModalWithErrorMessage@3x.png differ