diff --git a/Kickstarter-iOS/Features/PaymentMethods/Controller/PaymentMethodsViewController.swift b/Kickstarter-iOS/Features/PaymentMethods/Controller/PaymentMethodsViewController.swift index 12d7a67e99..de675a14a4 100644 --- a/Kickstarter-iOS/Features/PaymentMethods/Controller/PaymentMethodsViewController.swift +++ b/Kickstarter-iOS/Features/PaymentMethods/Controller/PaymentMethodsViewController.swift @@ -1,11 +1,13 @@ import KsApi import Library import Prelude +import Stripe import UIKit internal final class PaymentMethodsViewController: UIViewController, MessageBannerViewControllerPresenting { private let dataSource = PaymentMethodsDataSource() private let viewModel: PaymentMethodsViewModelType = PaymentMethodsViewModel() + private var paymentSheetFlowController: PaymentSheet.FlowController? @IBOutlet private var tableView: UITableView! @@ -93,7 +95,15 @@ internal final class PaymentMethodsViewController: UIViewController, MessageBann self?.goToAddCardScreen(with: intent) } - self.viewModel.outputs.errorLoadingPaymentMethods + self.viewModel.outputs.goToPaymentSheet + .observeForUI() + .observeValues { [weak self] data in + guard let strongSelf = self else { return } + + strongSelf.goToPaymentSheet(data: data) + } + + self.viewModel.outputs.errorLoadingPaymentMethodsOrSetupIntent .observeForUI() .observeValues { [weak self] message in self?.messageBannerViewController?.showBanner(with: .error, message: message) @@ -143,6 +153,69 @@ internal final class PaymentMethodsViewController: UIViewController, MessageBann // MARK: - Private Helpers + private func goToPaymentSheet(data: PaymentSheetSetupData) { + PaymentSheet.FlowController + .create( + setupIntentClientSecret: data.clientSecret, + configuration: data.configuration + ) { [weak self] result in + guard let strongSelf = self else { return } + + switch result { + case let .failure(error): + /** TODO: https://kickstarter.atlassian.net/browse/PAY-1954 + * strongSelf.viewModel.inputs.shouldCancelPaymentSheetAppearance(state: true) + */ + + strongSelf.messageBannerViewController? + .showBanner(with: .error, message: error.localizedDescription) + case let .success(paymentSheetFlowController): + strongSelf.paymentSheetFlowController = paymentSheetFlowController + strongSelf.paymentSheetFlowController?.presentPaymentOptions(from: strongSelf) { [weak self] in + guard let strongSelf = self else { return } + + /** TODO: https://kickstarter.atlassian.net/browse/PAY-1900 + * strongSelf.confirmPaymentResult(with: data.clientSecret) + */ + } + } + } + } + + private func confirmPaymentResult(with _: String) { + guard self.paymentSheetFlowController?.paymentOption != nil else { + /** TODO: https://kickstarter.atlassian.net/browse/PAY-1954 + * strongSelf.viewModel.inputs.shouldCancelPaymentSheetAppearance(state: true) + */ + + return + } + + self.paymentSheetFlowController?.confirm(from: self) { [weak self] paymentResult in + + guard let strongSelf = self else { return } + + /** TODO: https://kickstarter.atlassian.net/browse/PAY-1954 + * strongSelf.viewModel.inputs.shouldCancelPaymentSheetAppearance(state: true) + */ + + guard let existingPaymentOption = strongSelf.paymentSheetFlowController?.paymentOption else { return } + + switch paymentResult { + case .completed: + /** TODO: https://kickstarter.atlassian.net/browse/PAY-1898 + * strongSelf.viewModel.inputs.paymentSheetDidAdd(newCard: existingPaymentOption, setupIntent: clientSecret) + */ + fatalError() + case .canceled: + strongSelf.messageBannerViewController? + .showBanner(with: .error, message: Strings.general_error_something_wrong()) + case let .failed(error): + strongSelf.messageBannerViewController?.showBanner(with: .error, message: error.localizedDescription) + } + } + } + private func configureHeaderFooterViews() { if let header = SettingsTableViewHeader.fromNib(nib: Nib.SettingsTableViewHeader) { header.configure(with: Strings.Any_payment_methods_you_saved_to_Kickstarter()) diff --git a/KsApi/GraphAPI.swift b/KsApi/GraphAPI.swift index de854d4fac..cdb6abc9fc 100644 --- a/KsApi/GraphAPI.swift +++ b/KsApi/GraphAPI.swift @@ -254,23 +254,24 @@ public enum GraphAPI { /// - stripeToken /// - stripeCardId /// - reusable + /// - intentClientSecret /// - clientMutationId: A unique identifier for the client performing the mutation. - public init(paymentType: PaymentTypes, stripeToken: String, stripeCardId: Swift.Optional = nil, reusable: Swift.Optional = nil, clientMutationId: Swift.Optional = nil) { - graphQLMap = ["paymentType": paymentType, "stripeToken": stripeToken, "stripeCardId": stripeCardId, "reusable": reusable, "clientMutationId": clientMutationId] + public init(paymentType: Swift.Optional = nil, stripeToken: Swift.Optional = nil, stripeCardId: Swift.Optional = nil, reusable: Swift.Optional = nil, intentClientSecret: Swift.Optional = nil, clientMutationId: Swift.Optional = nil) { + graphQLMap = ["paymentType": paymentType, "stripeToken": stripeToken, "stripeCardId": stripeCardId, "reusable": reusable, "intentClientSecret": intentClientSecret, "clientMutationId": clientMutationId] } - public var paymentType: PaymentTypes { + public var paymentType: Swift.Optional { get { - return graphQLMap["paymentType"] as! PaymentTypes + return graphQLMap["paymentType"] as? Swift.Optional ?? Swift.Optional.none } set { graphQLMap.updateValue(newValue, forKey: "paymentType") } } - public var stripeToken: String { + public var stripeToken: Swift.Optional { get { - return graphQLMap["stripeToken"] as! String + return graphQLMap["stripeToken"] as? Swift.Optional ?? Swift.Optional.none } set { graphQLMap.updateValue(newValue, forKey: "stripeToken") @@ -295,6 +296,15 @@ public enum GraphAPI { } } + public var intentClientSecret: Swift.Optional { + get { + return graphQLMap["intentClientSecret"] as? Swift.Optional ?? Swift.Optional.none + } + set { + graphQLMap.updateValue(newValue, forKey: "intentClientSecret") + } + } + /// A unique identifier for the client performing the mutation. public var clientMutationId: Swift.Optional { get { @@ -349,13 +359,13 @@ public enum GraphAPI { /// - Parameters: /// - projectId /// - clientMutationId: A unique identifier for the client performing the mutation. - public init(projectId: GraphQLID, clientMutationId: Swift.Optional = nil) { + public init(projectId: Swift.Optional = nil, clientMutationId: Swift.Optional = nil) { graphQLMap = ["projectId": projectId, "clientMutationId": clientMutationId] } - public var projectId: GraphQLID { + public var projectId: Swift.Optional { get { - return graphQLMap["projectId"] as! GraphQLID + return graphQLMap["projectId"] as? Swift.Optional ?? Swift.Optional.none } set { graphQLMap.updateValue(newValue, forKey: "projectId") diff --git a/KsApi/graphql-schema.json b/KsApi/graphql-schema.json index 07fd5570b7..9e4cb2ee6e 100644 --- a/KsApi/graphql-schema.json +++ b/KsApi/graphql-schema.json @@ -605,86 +605,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "patron", - "description": "Fetches a patron given its slug.", - "args": [ - { - "name": "slug", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "Patron", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "patrons", - "description": null, - "args": [ - { - "name": "first", - "description": "Returns the first _n_ elements from the list.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": "Returns the elements in the list that come after the specified cursor.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "last", - "description": "Returns the last _n_ elements from the list.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "before", - "description": "Returns the elements in the list that come before the specified cursor.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PatronConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "post", "description": "Fetches a post given its ID.", @@ -11537,25 +11457,31 @@ "deprecationReason": null }, { - "name": "stripe_connect_onboarding_only_for_kyc", + "name": "creator_onboarding_flow_2021", "description": null, "isDeprecated": false, "deprecationReason": null }, { - "name": "creator_onboarding_flow_2021", + "name": "updated_risks_flow", "description": null, "isDeprecated": false, "deprecationReason": null }, { - "name": "updated_risks_flow", + "name": "payment_element_web_checkout_2022", "description": null, "isDeprecated": false, "deprecationReason": null }, { - "name": "payment_element_web_checkout_2022", + "name": "payment_element_user_settings_2022", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_element_project_build_2022", "description": null, "isDeprecated": false, "deprecationReason": null @@ -11583,6 +11509,12 @@ "description": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "use_new_blog_url", + "description": null, + "isDeprecated": false, + "deprecationReason": null } ], "possibleTypes": null @@ -28122,283 +28054,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "OBJECT", - "name": "Patron", - "description": "A patron on Kickstarter.", - "fields": [ - { - "name": "about", - "description": "About this patron.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "criteria", - "description": "What this patron looks for in a project.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": "Description of this patron.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fundRemaining", - "description": "Fund size remaining for this patron", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Money", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fundSize", - "description": "Fund size for this patron.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Money", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "how_to_apply", - "description": "How to submit a project to this patron.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "slug", - "description": "Patron's url slug.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "spentToDate", - "description": "The amount the patron has pledged to live and successful projects", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Money", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "support", - "description": "Additional support provided by this patron.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": "A URL to the patron's profile page.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "user", - "description": "The user that backs for this patron.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "User", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PatronConnection", - "description": "The connection type for Patron.", - "fields": [ - { - "name": "edges", - "description": "A list of edges.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PatronEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "nodes", - "description": "A list of nodes.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Patron", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information to aid in pagination.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "totalCount", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PatronEdge", - "description": "An edge in a connection.", - "fields": [ - { - "name": "cursor", - "description": "A cursor for use in pagination.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The item at the end of the edge.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Patron", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, { "kind": "ENUM", "name": "GoalBuckets", @@ -34893,13 +34548,9 @@ "name": "paymentType", "description": null, "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PaymentTypes", - "ofType": null - } + "kind": "ENUM", + "name": "PaymentTypes", + "ofType": null }, "defaultValue": null }, @@ -34907,13 +34558,9 @@ "name": "stripeToken", "description": null, "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } + "kind": "SCALAR", + "name": "String", + "ofType": null }, "defaultValue": null }, @@ -34937,6 +34584,16 @@ }, "defaultValue": null }, + { + "name": "intentClientSecret", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, { "name": "clientMutationId", "description": "A unique identifier for the client performing the mutation.", @@ -36269,13 +35926,9 @@ "name": "projectId", "description": null, "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } + "kind": "SCALAR", + "name": "ID", + "ofType": null }, "defaultValue": null }, diff --git a/KsApi/mutations/inputs/CreateSetupIntentInput.swift b/KsApi/mutations/inputs/CreateSetupIntentInput.swift index 5652817cbe..18b09e8e9e 100644 --- a/KsApi/mutations/inputs/CreateSetupIntentInput.swift +++ b/KsApi/mutations/inputs/CreateSetupIntentInput.swift @@ -1,17 +1,17 @@ import Foundation public struct CreateSetupIntentInput: GraphMutationInput, Encodable { - let projectId: String + let projectId: String? /** An input object for the CreateSetupIntentMutation - parameter projectId: A project id that is needed by GraphQL to generate a client secret. */ - public init(projectId: String) { + public init(projectId: String?) { self.projectId = projectId } - public func toInputDictionary() -> [String: Any] { + public func toInputDictionary() -> [String: Any?] { return [ "projectId": self.projectId ] diff --git a/KsApi/mutations/inputs/CreateSetupIntentInputTests.swift b/KsApi/mutations/inputs/CreateSetupIntentInputTests.swift index 93545035db..9589d29104 100644 --- a/KsApi/mutations/inputs/CreateSetupIntentInputTests.swift +++ b/KsApi/mutations/inputs/CreateSetupIntentInputTests.swift @@ -3,11 +3,19 @@ import Prelude import XCTest final class CreateSetupIntentInputTests: XCTestCase { - func testCreateSetupIntentInputDictionary() { + func testCreateSetupIntentInputDictionary_WithValue_Success() { let createSetupIntentInput = CreateSetupIntentInput(projectId: "UHJvamVjdC0yMzEyODc5ODc") let input = createSetupIntentInput.toInputDictionary() XCTAssertEqual(input["projectId"] as? String, "UHJvamVjdC0yMzEyODc5ODc") } + + func testCreateSetupIntentInputDictionary_WithNoValue_Success() { + let createSetupIntentInput = CreateSetupIntentInput(projectId: nil) + + let input = createSetupIntentInput.toInputDictionary() + + XCTAssertNil(input["projectId"] as? String) + } } diff --git a/Library/ViewModels/PaymentMethodsViewModel.swift b/Library/ViewModels/PaymentMethodsViewModel.swift index fc89467bbd..3db5bb620c 100644 --- a/Library/ViewModels/PaymentMethodsViewModel.swift +++ b/Library/ViewModels/PaymentMethodsViewModel.swift @@ -2,6 +2,7 @@ import Foundation import KsApi import Prelude import ReactiveSwift +import Stripe public protocol PaymentMethodsViewModelInputs { func addNewCardSucceeded(with message: String) @@ -16,8 +17,9 @@ public protocol PaymentMethodsViewModelInputs { public protocol PaymentMethodsViewModelOutputs { var editButtonIsEnabled: Signal { get } var editButtonTitle: Signal { get } - var errorLoadingPaymentMethods: Signal { get } + var errorLoadingPaymentMethodsOrSetupIntent: Signal { get } var goToAddCardScreenWithIntent: Signal { get } + var goToPaymentSheet: Signal { get } var paymentMethods: Signal<[UserCreditCards.CreditCard], Never> { get } var presentBanner: Signal { get } var reloadData: Signal { get } @@ -46,6 +48,10 @@ public final class PaymentMethodsViewModel: PaymentMethodsViewModelType, .materialize() } + lazy var paymentSheetEnabled: Bool = { + featureSettingsPaymentSheetEnabled() + }() + let deletePaymentMethodEvents = self.didDeleteCreditCardSignal .map(first) .switchMap { creditCard in @@ -73,8 +79,6 @@ public final class PaymentMethodsViewModel: PaymentMethodsViewModelType, deletePaymentMethodValues ) - self.errorLoadingPaymentMethods = paymentMethodsEvent.errors().map { $0.localizedDescription } - self.paymentMethods = Signal.merge( initialPaymentMethodsValues, deletePaymentMethodEventsErrors @@ -98,6 +102,8 @@ public final class PaymentMethodsViewModel: PaymentMethodsViewModelType, .skipRepeats() self.goToAddCardScreenWithIntent = self.didTapAddCardButtonProperty.signal + .switchMap { SignalProducer(value: paymentSheetEnabled) } + .filter(isFalse) .mapConst(.settings) self.presentBanner = self.addNewCardSucceededProperty.signal.skipNil() @@ -119,6 +125,38 @@ public final class PaymentMethodsViewModel: PaymentMethodsViewModelType, self.editButtonTitle = self.tableViewIsEditing .map { $0 ? Strings.Done() : Strings.discovery_favorite_categories_buttons_edit() } + + let createSetupIntentEvent = self.didTapAddCardButtonProperty.signal + .switchMap { SignalProducer(value: paymentSheetEnabled) } + .filter(isTrue) + .switchMap { _ -> SignalProducer.Event, Never> in + AppEnvironment.current.apiService + .createStripeSetupIntent(input: CreateSetupIntentInput(projectId: nil)) + .ksr_debounce(.seconds(1), on: AppEnvironment.current.scheduler) + .ksr_delay(AppEnvironment.current.apiDelayInterval, on: AppEnvironment.current.scheduler) + .switchMap { envelope -> SignalProducer in + var configuration = PaymentSheet.Configuration() + configuration.merchantDisplayName = Strings.general_accessibility_kickstarter() + configuration.allowsDelayedPaymentMethods = true + let data = PaymentSheetSetupData( + clientSecret: envelope.clientSecret, + configuration: configuration + ) + return SignalProducer(value: data) + } + .materialize() + } + + /** TODO: https://kickstarter.atlassian.net/browse/PAY-1954 + * Add cancellation signal similiar to `shouldCancelPaymentSheetAppearance` in `PledgePaymentMethodsViewModel` + */ + self.goToPaymentSheet = createSetupIntentEvent.values() + + self.errorLoadingPaymentMethodsOrSetupIntent = Signal.merge( + paymentMethodsEvent.errors(), + createSetupIntentEvent.errors() + ) + .map { $0.localizedDescription } } // Stores the table view's editing state as it is affected by multiple signals @@ -165,8 +203,9 @@ public final class PaymentMethodsViewModel: PaymentMethodsViewModelType, public let editButtonIsEnabled: Signal public let editButtonTitle: Signal - public let errorLoadingPaymentMethods: Signal + public let errorLoadingPaymentMethodsOrSetupIntent: Signal public let goToAddCardScreenWithIntent: Signal + public let goToPaymentSheet: Signal public let paymentMethods: Signal<[UserCreditCards.CreditCard], Never> public let presentBanner: Signal public let reloadData: Signal diff --git a/Library/ViewModels/PaymentMethodsViewModelTests.swift b/Library/ViewModels/PaymentMethodsViewModelTests.swift index e09a6923e8..ff7e907868 100644 --- a/Library/ViewModels/PaymentMethodsViewModelTests.swift +++ b/Library/ViewModels/PaymentMethodsViewModelTests.swift @@ -11,8 +11,9 @@ internal final class PaymentMethodsViewModelTests: TestCase { private let userTemplate = GraphUser.template |> \.storedCards .~ UserCreditCards.template private let editButtonIsEnabled = TestObserver() private let editButtonTitle = TestObserver() - private let errorLoadingPaymentMethods = TestObserver() + private let errorLoadingPaymentMethodsOrSetupIntent = TestObserver() private let goToAddCardScreenWithIntent = TestObserver() + private let goToPaymentSheet = TestObserver() private let paymentMethods = TestObserver<[UserCreditCards.CreditCard], Never>() private let presentBanner = TestObserver() private let reloadData = TestObserver() @@ -24,8 +25,10 @@ internal final class PaymentMethodsViewModelTests: TestCase { self.vm.outputs.editButtonIsEnabled.observe(self.editButtonIsEnabled.observer) self.vm.outputs.editButtonTitle.observe(self.editButtonTitle.observer) - self.vm.outputs.errorLoadingPaymentMethods.observe(self.errorLoadingPaymentMethods.observer) + self.vm.outputs.errorLoadingPaymentMethodsOrSetupIntent + .observe(self.errorLoadingPaymentMethodsOrSetupIntent.observer) self.vm.outputs.goToAddCardScreenWithIntent.observe(self.goToAddCardScreenWithIntent.observer) + self.vm.outputs.goToPaymentSheet.observe(self.goToPaymentSheet.observer) self.vm.outputs.paymentMethods.observe(self.paymentMethods.observer) self.vm.outputs.presentBanner.observe(self.presentBanner.observer) self.vm.outputs.reloadData.observe(self.reloadData.observer) @@ -58,11 +61,35 @@ internal final class PaymentMethodsViewModelTests: TestCase { self.scheduler.advance() - self.errorLoadingPaymentMethods.assertValue(ErrorEnvelope.couldNotParseJSON.localizedDescription) + self.errorLoadingPaymentMethodsOrSetupIntent + .assertValue(ErrorEnvelope.couldNotParseJSON.localizedDescription) self.paymentMethods.assertDidNotEmitValue() } } + func testPaymentMethodsFetch_errorFetchingSetupIntent() { + let mockService = MockService(createStripeSetupIntentResult: .failure(.couldNotParseJSON)) + + let mockOptimizelyClient = MockOptimizelyClient() + |> \.features .~ [ + OptimizelyFeature.settingsPaymentSheetEnabled.rawValue: true + ] + + withEnvironment( + apiService: mockService, + optimizelyClient: mockOptimizelyClient + ) { + self.errorLoadingPaymentMethodsOrSetupIntent.assertDidNotEmitValue() + + self.vm.inputs.paymentMethodsFooterViewDidTapAddNewCardButton() + + self.scheduler.advance() + + self.errorLoadingPaymentMethodsOrSetupIntent + .assertValue(ErrorEnvelope.couldNotParseJSON.localizedDescription) + } + } + func testPaymentMethodsFetch_OnAddNewCardSucceeded() { let response = UserEnvelope(me: userTemplate) let apiService = MockService(fetchGraphUserResult: .success(response)) @@ -215,12 +242,62 @@ internal final class PaymentMethodsViewModelTests: TestCase { } } - func testGoToAddCardScreenEmits_WhenAddNewCardIsTapped() { - self.goToAddCardScreenWithIntent.assertValueCount(0) + func testGoToAddCardScreenEmits_WhenAddNewCardIsTapped_PaymentSheetFlagFalse_Success() { + let mockOptimizelyClient = MockOptimizelyClient() + |> \.features .~ [ + OptimizelyFeature.settingsPaymentSheetEnabled.rawValue: false + ] + + withEnvironment(optimizelyClient: mockOptimizelyClient) { + self.goToAddCardScreenWithIntent.assertValueCount(0) + + self.vm.inputs.paymentMethodsFooterViewDidTapAddNewCardButton() + + self.scheduler.advance() + + self.goToAddCardScreenWithIntent.assertValues([.settings], "Should emit after tapping button") + } + } + + func testGoToAddCardScreenEmits_WhenAddNewCardIsTapped_PaymentSheetFlagTrue_Failure() { + let mockOptimizelyClient = MockOptimizelyClient() + |> \.features .~ [ + OptimizelyFeature.settingsPaymentSheetEnabled.rawValue: true + ] + + withEnvironment(optimizelyClient: mockOptimizelyClient) { + self.goToAddCardScreenWithIntent.assertValueCount(0) + + self.vm.inputs.paymentMethodsFooterViewDidTapAddNewCardButton() + + self.scheduler.advance() + + self.goToAddCardScreenWithIntent.assertValueCount(0) + } + } + + func testGoToPaymentSheet_WhenAddNewCardIsTapped_PaymentSheetFlagTrue_Success() { + let envelope = ClientSecretEnvelope(clientSecret: "UHJvamVjdC0yMzEyODc5ODc") + let mockService = MockService(createStripeSetupIntentResult: .success(envelope)) + let mockOptimizelyClient = MockOptimizelyClient() + |> \.features .~ [ + OptimizelyFeature.settingsPaymentSheetEnabled.rawValue: true + ] - self.vm.inputs.paymentMethodsFooterViewDidTapAddNewCardButton() + withEnvironment( + apiService: mockService, + optimizelyClient: mockOptimizelyClient + ) { + self.goToAddCardScreenWithIntent.assertValueCount(0) + self.goToPaymentSheet.assertValueCount(0) - self.goToAddCardScreenWithIntent.assertValues([.settings], "Should emit after tapping button") + self.vm.inputs.paymentMethodsFooterViewDidTapAddNewCardButton() + + self.scheduler.advance(by: .seconds(1)) + + self.goToAddCardScreenWithIntent.assertValueCount(0) + self.goToPaymentSheet.assertValueCount(1) + } } func testTableViewIsEditing_isFalse_WhenAddNewCardIsPresented() {