Skip to content

Commit

Permalink
[MBL-1393] Moves Stripe Intent Logic Into It's Own Service (#2050)
Browse files Browse the repository at this point in the history
* Create StripeIntentService that creates a new payment intent

* update existing .createPaymentIntent call sites

* Add setup intent func to StripeIntentServiceType

* update existing createStripeSetupIntent call sites

* convert service to class so that it can be tested more easily

* tests for StripeSetupIntentService

* clean up tests

* inject stripe intent service only where needed instead of putting it in our already large AppEnvironment

* remove tests

* update MockStripeIntentService to use apiService

* This way it's a better representation of the actual service

* inject MockStripeIntentService into PledgePaymentMethodsViewModelTests and update tests

* inject MockStripeIntentService into PaymentMethodSettingsViewModelTests and update tests

* inject MockStripeIntentService into PostCampaignCheckoutViewModelTests and update tests

* make assertion comment clearer

* cleanup from initial testing pattern
  • Loading branch information
scottkicks authored May 14, 2024
1 parent 8e37fd7 commit 52f6afe
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ protocol PaymentMethodSettingsViewControllerDelegate: AnyObject {
internal final class PaymentMethodSettingsViewController: UIViewController,
MessageBannerViewControllerPresenting {
private let dataSource = PaymentMethodsDataSource()
private let viewModel: PaymentMethodsViewModelType = PaymentMethodSettingsViewModel()
private let viewModel: PaymentMethodsViewModelType =
PaymentMethodSettingsViewModel(stripeIntentService: StripeIntentService())
private var paymentSheetFlowController: PaymentSheet.FlowController?
private weak var cancellationDelegate: PaymentMethodSettingsViewControllerDelegate?
@IBOutlet private var tableView: UITableView!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ final class PledgePaymentMethodsViewController: UIViewController {

internal weak var delegate: PledgePaymentMethodsViewControllerDelegate?
internal weak var messageDisplayingDelegate: PledgeViewControllerMessageDisplaying?
private let viewModel: PledgePaymentMethodsViewModelType = PledgePaymentMethodsViewModel()
private let viewModel: PledgePaymentMethodsViewModelType =
PledgePaymentMethodsViewModel(stripeIntentService: StripeIntentService())
private var paymentSheetFlowController: PaymentSheet.FlowController?

// MARK: - Lifecycle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ final class PostCampaignCheckoutViewController: UIViewController,
|> \.translatesAutoresizingMaskIntoConstraints .~ false
}()

private let viewModel: PostCampaignCheckoutViewModelType = PostCampaignCheckoutViewModel()
private let viewModel: PostCampaignCheckoutViewModelType =
PostCampaignCheckoutViewModel(stripeIntentService: StripeIntentService())

// MARK: - Lifecycle

Expand Down
8 changes: 8 additions & 0 deletions Kickstarter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@
609309952A6055A5004297AF /* TriggerThirdPartyEvent.graphql in Resources */ = {isa = PBXBuildFile; fileRef = 609309942A6055A5004297AF /* TriggerThirdPartyEvent.graphql */; };
60A3ED252B85361E008E1BA8 /* RewardsCollectionViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A3ED232B853618008E1BA8 /* RewardsCollectionViewHeaderView.swift */; };
60A80F532BD7F9A00052A829 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 60A80F522BD7F9A00052A829 /* PrivacyInfo.xcprivacy */; };
60A80F552BE003A60052A829 /* StripeIntentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A80F542BE003A60052A829 /* StripeIntentService.swift */; };
60A80F622BEA86A80052A829 /* MockStripeIntentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A80F612BEA86A80052A829 /* MockStripeIntentService.swift */; };
60AE9F062ABB897900FB3A96 /* ReportProjectInfoListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60AE9F022ABB822300FB3A96 /* ReportProjectInfoListItem.swift */; };
60C996E42ABCA5E5006BE4F4 /* ReportProjectLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C996E32ABCA5E5006BE4F4 /* ReportProjectLabelView.swift */; };
60C996E62ABCC002006BE4F4 /* ReportProjectCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C996E52ABCC002006BE4F4 /* ReportProjectCell.swift */; };
Expand Down Expand Up @@ -2120,6 +2122,8 @@
609309942A6055A5004297AF /* TriggerThirdPartyEvent.graphql */ = {isa = PBXFileReference; lastKnownFileType = text; path = TriggerThirdPartyEvent.graphql; sourceTree = "<group>"; };
60A3ED232B853618008E1BA8 /* RewardsCollectionViewHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RewardsCollectionViewHeaderView.swift; sourceTree = "<group>"; };
60A80F522BD7F9A00052A829 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
60A80F542BE003A60052A829 /* StripeIntentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeIntentService.swift; sourceTree = "<group>"; };
60A80F612BEA86A80052A829 /* MockStripeIntentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStripeIntentService.swift; sourceTree = "<group>"; };
60AE9F022ABB822300FB3A96 /* ReportProjectInfoListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProjectInfoListItem.swift; sourceTree = "<group>"; };
60C996E32ABCA5E5006BE4F4 /* ReportProjectLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProjectLabelView.swift; sourceTree = "<group>"; };
60C996E52ABCC002006BE4F4 /* ReportProjectCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProjectCell.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6201,6 +6205,7 @@
8016BFE71D0F582D00067956 /* String+Whitespace.swift */,
A7ED1F221E830FDC00BFFA01 /* String+WhitespaceTests.swift */,
A79BF81E1D11F6AF004C0445 /* Strings.swift */,
60A80F542BE003A60052A829 /* StripeIntentService.swift */,
8AD48632235939AF00A1463E /* StripeTypes.swift */,
6080DA3E2AB366550088EF3D /* SwiftUI+Extensions */,
94F4A95926125C8C000C21F9 /* TimeInterval+ISO8601Date.swift */,
Expand Down Expand Up @@ -6353,6 +6358,7 @@
D033E2C122A05B4800464E43 /* MockApplication.swift */,
6008633E29BF750700B87B39 /* MockAppTrackingTransparency.swift */,
A7ED1F451E831BA200BFFA01 /* MockBundle.swift */,
60A80F612BEA86A80052A829 /* MockStripeIntentService.swift */,
1611EF6823B275700051CDCC /* MockUUID.swift */,
1923770928DA2AE300F68635 /* Stripe+PaymentMethod.swift */,
A7ED1F461E831BA200BFFA01 /* TestCase.swift */,
Expand Down Expand Up @@ -7775,6 +7781,7 @@
4791BDE6271762E600DFE5D5 /* ProjectFAQsCellViewModel.swift in Sources */,
0634C2F927CFEEC2003A6D6E /* ExternalSourceViewElementCellViewModel.swift in Sources */,
94BA16E426698C8B0034CC3F /* CommentTableViewFooterViewModel.swift in Sources */,
60A80F552BE003A60052A829 /* StripeIntentService.swift in Sources */,
77D19FF5240813240058FC8E /* NavigationController.swift in Sources */,
8A8099F422E2142C00373E66 /* RewardCardContainerViewModel.swift in Sources */,
A75511631C8642C3005355CF /* LocalizedString.swift in Sources */,
Expand Down Expand Up @@ -8028,6 +8035,7 @@
D04AACA8218BB72100CF713E /* FindFriendsCellViewModelTests.swift in Sources */,
D6BD66BD23CCF7B6008694BB /* EquatableHelpersTests.swift in Sources */,
D04AACA6218BB72100CF713E /* ChangePasswordViewModelTests.swift in Sources */,
60A80F622BEA86A80052A829 /* MockStripeIntentService.swift in Sources */,
A7ED20041E831C5C00BFFA01 /* UpdateDraftViewModelTests.swift in Sources */,
A7ED1F4B1E831BA200BFFA01 /* TestCase.swift in Sources */,
A7ED1FBE1E831C5C00BFFA01 /* ProjectNotificationCellViewModelTests.swift in Sources */,
Expand Down
59 changes: 59 additions & 0 deletions Library/StripeIntentService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import KsApi
import ReactiveSwift

public protocol StripeIntentServiceType {
func createPaymentIntent(for projectId: String, pledgeTotal: Double)
-> SignalProducer<PaymentIntentEnvelope, ErrorEnvelope>
func createSetupIntent(
for projectId: String?,
context: GraphAPI.StripeIntentContextTypes
) -> SignalProducer<ClientSecretEnvelope, ErrorEnvelope>
}

/// This is the module that creates either a Stripe payment intent or a setup intent.
public class StripeIntentService: StripeIntentServiceType {
public init() {}

/**
Returns a signal producer that emits a `PaymentIntentEnvelope` or `ErrorEnvelope` value representing whether or not a payment intent was created and returned successfully.
The returned producer emits once and completes.
- parameter for: The types to register that we will request permissions for.
- parameters:
- projectId: The GraphID of a project
- pledgeTotal: The final pledge total of the current pledge
*/

public func createPaymentIntent(
for projectId: String,
pledgeTotal: Double
) -> SignalProducer<PaymentIntentEnvelope, ErrorEnvelope> {
AppEnvironment.current.apiService
.createPaymentIntentInput(input: CreatePaymentIntentInput(
projectId: projectId,
amountDollars: String(format: "%.2f", pledgeTotal),
digitalMarketingAttributed: nil
))
}

/**
Returns a signal producer that contains a `ClientSecretEnvelope` or `ErrorEnvelope` representing whether or not a setup intent was created and returned successfully.
The returned producer emits once and completes.
- parameter for: The types to register that we will request permissions for.
- parameters:
- projectId: The optional GraphID of a project
- context: The context for which this intent is being created
*/

public func createSetupIntent(
for projectId: String?,
context: GraphAPI.StripeIntentContextTypes
) -> SignalProducer<ClientSecretEnvelope, ErrorEnvelope> {
AppEnvironment.current.apiService
.createStripeSetupIntent(
input: CreateSetupIntentInput(projectId: projectId, context: context)
)
}
}
46 changes: 46 additions & 0 deletions Library/TestHelpers/MockStripeIntentService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@testable import KsApi
@testable import Library
import ReactiveSwift

public class MockStripeIntentService: StripeIntentServiceType {
public private(set) var setupIntentRequests: Int = 0
public private(set) var paymentIntentRequests: Int = 0

public init() {}

public func createPaymentIntent(
for projectId: String,
pledgeTotal: Double
) -> SignalProducer<PaymentIntentEnvelope, ErrorEnvelope> {
assert(
AppEnvironment.current.apiService as? MockService != nil,
"AppEnvironment.current.apiService should be a MockService when used in test."
)

self.paymentIntentRequests += 1

return AppEnvironment.current.apiService
.createPaymentIntentInput(input: CreatePaymentIntentInput(
projectId: projectId,
amountDollars: String(format: "%.2f", pledgeTotal),
digitalMarketingAttributed: nil
))
}

public func createSetupIntent(
for projectId: String?,
context: GraphAPI.StripeIntentContextTypes
) -> SignalProducer<ClientSecretEnvelope, ErrorEnvelope> {
assert(
AppEnvironment.current.apiService as? MockService != nil,
"AppEnvironment.current.apiService should be a MockService when used in test."
)

self.setupIntentRequests += 1

return AppEnvironment.current.apiService
.createStripeSetupIntent(
input: CreateSetupIntentInput(projectId: projectId, context: context)
)
}
}
11 changes: 6 additions & 5 deletions Library/ViewModels/PaymentMethodSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ public protocol PaymentMethodsViewModelType {

public final class PaymentMethodSettingsViewModel: PaymentMethodsViewModelType,
PaymentMethodSettingsViewModelInputs, PaymentMethodSettingsViewModelOutputs {
public init() {
let stripeIntentService: StripeIntentServiceType

public init(stripeIntentService: StripeIntentServiceType) {
self.stripeIntentService = stripeIntentService

self.reloadData = self.viewDidLoadProperty.signal

let paymentMethodsEvent = Signal.merge(
Expand Down Expand Up @@ -147,10 +151,7 @@ public final class PaymentMethodSettingsViewModel: PaymentMethodsViewModelType,
.switchMap { SignalProducer(value: paymentSheetEnabled) }
.filter(isTrue)
.switchMap { _ -> SignalProducer<Signal<PaymentSheetSetupData, ErrorEnvelope>.Event, Never> in
AppEnvironment.current.apiService
.createStripeSetupIntent(
input: CreateSetupIntentInput(projectId: nil, context: .profileSettings)
)
stripeIntentService.createSetupIntent(for: nil, context: .profileSettings)
.ksr_debounce(.seconds(1), on: AppEnvironment.current.scheduler)
.ksr_delay(AppEnvironment.current.apiDelayInterval, on: AppEnvironment.current.scheduler)
.switchMap { envelope -> SignalProducer<PaymentSheetSetupData, ErrorEnvelope> in
Expand Down
15 changes: 14 additions & 1 deletion Library/ViewModels/PaymentMethodSettingsViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import ReactiveSwift
import XCTest

internal final class PaymentMethodSettingsViewModelTests: TestCase {
private let vm = PaymentMethodSettingsViewModel()
private var vm = PaymentMethodSettingsViewModel(stripeIntentService: MockStripeIntentService())
private let mockStripeIntentService = MockStripeIntentService()

private let userTemplate = GraphUser.template |> \.storedCards .~ UserCreditCards.template
private let cancelLoadingState = TestObserver<Void, Never>()
private let editButtonIsEnabled = TestObserver<Bool, Never>()
Expand All @@ -25,6 +27,8 @@ internal final class PaymentMethodSettingsViewModelTests: TestCase {
internal override func setUp() {
super.setUp()

self.vm = PaymentMethodSettingsViewModel(stripeIntentService: self.mockStripeIntentService)

self.vm.outputs.cancelAddNewCardLoadingState.observe(self.cancelLoadingState.observer)
self.vm.outputs.editButtonIsEnabled.observe(self.editButtonIsEnabled.observer)
self.vm.outputs.editButtonTitle.observe(self.editButtonTitle.observer)
Expand Down Expand Up @@ -96,6 +100,9 @@ internal final class PaymentMethodSettingsViewModelTests: TestCase {

self.errorLoadingPaymentMethodsOrSetupIntent
.assertValue(ErrorEnvelope.couldNotParseJSON.localizedDescription)

XCTAssertEqual(self.mockStripeIntentService.setupIntentRequests, 1)
XCTAssertEqual(self.mockStripeIntentService.paymentIntentRequests, 0)
}
}

Expand Down Expand Up @@ -287,6 +294,9 @@ internal final class PaymentMethodSettingsViewModelTests: TestCase {
self.scheduler.advance(by: .seconds(1))

self.goToPaymentSheet.assertValueCount(1)

XCTAssertEqual(self.mockStripeIntentService.setupIntentRequests, 1)
XCTAssertEqual(self.mockStripeIntentService.paymentIntentRequests, 0)
}
}

Expand All @@ -309,6 +319,9 @@ internal final class PaymentMethodSettingsViewModelTests: TestCase {
self.scheduler.advance(by: .seconds(1))

self.tableViewIsEditing.assertValues([false, true, false])

XCTAssertEqual(self.mockStripeIntentService.setupIntentRequests, 1)
XCTAssertEqual(self.mockStripeIntentService.paymentIntentRequests, 0)
}
}

Expand Down
28 changes: 15 additions & 13 deletions Library/ViewModels/PledgePaymentMethodsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ public protocol PledgePaymentMethodsViewModelType {

public final class PledgePaymentMethodsViewModel: PledgePaymentMethodsViewModelType,
PledgePaymentMethodsViewModelInputs, PledgePaymentMethodsViewModelOutputs {
public init() {
let stripeIntentService: StripeIntentServiceType

public init(stripeIntentService: StripeIntentServiceType) {
self.stripeIntentService = stripeIntentService

let configureWithValue = Signal.combineLatest(
self.viewDidLoadProperty.signal,
self.configureWithValueProperty.signal.skipNil()
Expand Down Expand Up @@ -317,24 +321,22 @@ public final class PledgePaymentMethodsViewModel: PledgePaymentMethodsViewModelT
let setupIntentContext = pledgeContext == .latePledge
? GraphAPI.StripeIntentContextTypes.postCampaignCheckout
: GraphAPI.StripeIntentContextTypes.crowdfundingCheckout
clientSecretSignal = AppEnvironment.current.apiService
.createStripeSetupIntent(
input: CreateSetupIntentInput(projectId: project.graphID, context: setupIntentContext)
)
.map { $0.clientSecret }
clientSecretSignal = stripeIntentService.createSetupIntent(
for: project.graphID,
context: setupIntentContext
)
.map { $0.clientSecret }

case .paymentIntent:
assert(
!pledgeTotal.isNaN,
"Pledge total must be set when using a PaymentIntent. Did you accidentally get here via PledgeViewModel instead of PostCampaignCheckoutViewModel?"
)
clientSecretSignal = AppEnvironment.current.apiService
.createPaymentIntentInput(input: CreatePaymentIntentInput(
projectId: project.graphID,
amountDollars: String(format: "%.2f", pledgeTotal),
digitalMarketingAttributed: nil
))
.map { $0.clientSecret }
clientSecretSignal = stripeIntentService.createPaymentIntent(
for: project.graphID,
pledgeTotal: pledgeTotal
)
.map { $0.clientSecret }
}

return clientSecretSignal
Expand Down
Loading

0 comments on commit 52f6afe

Please sign in to comment.