Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NT-286][NT-360] SCA Support #902

Merged
merged 16 commits into from
Oct 23, 2019
Merged

[NT-286][NT-360] SCA Support #902

merged 16 commits into from
Oct 23, 2019

Conversation

justinswart
Copy link
Contributor

@justinswart justinswart commented Oct 18, 2019

📲 What

Adds SCA support to our create and update pledge flows.

🤔 Why

From the Stripe docs:

Strong Customer Authentication (SCA), a rule in effect as of September 14, 2019, as part of PSD2 regulation in Europe, requires changes to how your European customers authenticate online payments. Card payments require a different user experience, namely 3D Secure, in order to meet SCA requirements. Transactions that don’t follow the new authentication guidelines may be declined by your customers’ banks.

🛠 How

  • Added an output to PledgeViewModel to trigger the SCA flow.
  • Added an input to PledgeViewModel to handle the response (succeeded/failed/canceled).
  • During this process I noticed that we weren't being very DRY about handling the create and update signals. Their patterns are exactly the same so I refactored things to be more reusable. Tests passed without any changes except for the SCA stuff that was added.
  • Also introduced StripePaymentHandlerActionStatus (an enum) and StripePaymentHandlerActionStatusType (a protocol) that allows us to more easily mock the Stripe response enum in tests.

Note: I've renamed createApplePayBackingEvent to deprecatedCreateApplePayBackingEvent in the expectation that it will be removed soon when we've updated our CreateBacking mutation to accept ApplePayParams.

✅ Acceptance criteria

Using the test cards listed here:

Note: Please be aware of the caveats mentioned in this thread when testing.

  • Create a new pledge, the SCA test modal should be displayed.
  • Update a pledge, the SCA test modal should be displayed.
  • Change payment method from a non-SCA card to an SCA card, the SCA test modal should be displayed.
  • When creating a pledge, confirming in the SCA modal should dismiss and take you to the thanks page.
  • When updating a pledge, confirming in the SCA modal should dismiss with success and return to manage plege.
  • In any of the above scenarios producing an error from the modal (by way of an error test card), should dismiss and display the error in the error banner.
  • In any of the above scenarios dismissing the modal shouldn't do anything.
  • No Apple Pay pledges should trigger this SCA flow.

}
}

public enum StripePaymentHandlerActionStatus {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type maps to STPPaymentHandler.ActionStatus and makes it easier for us to mock in tests without having to link the Stripe SDK to our Library framework.

@@ -33,6 +33,7 @@ public protocol PledgeViewModelInputs {
)
func paymentAuthorizationViewControllerDidFinish()
func pledgeAmountViewControllerDidUpdate(with data: PledgeAmountData)
func scaFlowCompleted(with result: StripePaymentHandlerActionStatusType, error: Error?)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We pass in our protocol type here.

import Foundation
import KsApi

public protocol StripeSCARequiring {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This protocol helps to merge the signals in PledgeViewModel for simplification.

let updateBackingDidCompleteApplePay = updateApplePayBackingSuccess
.ignoreValues()
#warning("To be removed once new CreateBacking is implemented to handle Apple Pay")
let deprecatedCreateApplePayBackingCompleted = Signal.combineLatest(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-explanatory but this should be removed once we're handling Apple Pay through CreateBacking.

@@ -636,17 +667,23 @@ public class PledgeViewModel: PledgeViewModelType, PledgeViewModelInputs, Pledge
public let popViewController: Signal<(), Never>
public let sectionSeparatorsHidden: Signal<Bool, Never>
public let shippingLocationViewHidden: Signal<Bool, Never>
public let showErrorBannerWithMessage: Signal<String, Never>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors are all emitted through one output now.

@ksr-ci-bot
Copy link

1 Warning
⚠️ Big PR

Generated by 🚫 Danger

@@ -318,7 +315,7 @@ public class PledgeViewModel: PledgeViewModelType, PledgeViewModelInputs, Pledge
)
}

let createApplePayBackingEvent = createApplePayBackingData.map(
let deprecatedCreateApplePayBackingEvent = createApplePayBackingData.map(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is deprecated cuz we'll be merging it with createBacking soon right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, we should be able to remove this completely once that work is done.

STPPaymentHandler.shared().confirmSetupIntent(
withParams: .init(clientSecret: secret),
authenticationContext: self
) { [weak self] status, _, error in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious what's this middle parameter here that we're ignoring?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the SetupIntent object. I don't believe we need it for anything.

}
}

// MARK: STPAuthenticationContext
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add the - here

let createOrUpdateApplePayBackingCompleted = Signal.combineLatest(
Signal.merge(
updateBackingEvent.filter { $0.isTerminating }.ignoreValues(),
createBackingEvent.filter { $0.isTerminating }.ignoreValues()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be deprecatedCreateApplePayBackingEvent? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is handled on its own so that it'll be easy to remove later. This future-proofs for when CreateBacking supports Apple Pay.

self.createBackingError = Signal.merge(
createApplePayBackingError,
createBackingEventError
let createOrUpdateApplePayBackingCompleted = Signal.combineLatest(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this should be a zip to account for multiple groups of events?:

  1. User opens payment sheet and pledges - payment sheet dismisses, but pledge fails.
  2. User opens up payment sheet a second time and pledges - because the createBackingEvent.filter event has already fired once before, the next paymentAuthorizationDidFinish event will cause the createOrUpdateApplePayBackingCompleted to fire without waiting for the next request to terminate.

🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to write a failing test for it and then prove that scenario 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok @ifbarrera @Scollaco I've taken a new approach which clears the values for the create and update backing events each time a new flow is started. I think this gives us more confidence that we won't have overlapping values between flows being interrupted and new ones starting.

The tests are passing (all several thousand lines of them 😓).

Note right now I don't think the deprecatedCreateApplePayBacking needs this treatment because it's somewhat separate, but I didn't want to spend too much time on that given that we should remove it in the next PR.

.ignoreValues(),
updatePaymentAuthorizationDidFinishSignal
)
let didCompleteApplePayBacking = createOrUpdateBackingEventValues
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why do we need to take createOrUpdateBackingEventValues here? That seems to require either createBackingEvent.values() or updateBackingEvent.values().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this is future proof for when createBacking supports Apple Pay.

self.submitButtonEnabled.assertValues([false, true, false, true])
self.goToThanks.assertDidNotEmitValue()
self.showErrorBannerWithMessage.assertValues(
["The operation couldn’t be completed. (KsApi.GraphError error 5.)"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a strange error message. Shouldn't this be "Something went wrong." which is the localizedDescription of GraphError.invalidInput?

with: MockStripePaymentHandlerActionStatus(status: .succeeded), error: nil
)

self.beginSCAFlowWithClientSecret.assertValues(["client-secret"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be above the call to inputs.scaFlowCompleted no?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see it's already on line 1205, apologies!


let updateBackingEventErrors = updateBackingEvent.errors()
let createUpdateBackingEventErrors = Signal.merge(
deprecatedCreateApplePayBackingEvent.errors().map { $0 as Error },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you should have to do this casting to Error 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's because self.showErrorBannerWithMessage merges different types of errors (GraphError and Error). What if here we simplify to:

let createUpdateBackingEventErrors = Signal.merge(
      deprecatedCreateApplePayBackingEvent.errors(),
      createBackingEvent.errors(),
      updateBackingEvent.errors()
    ).map { $0 as Error }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the mapping to after the merge. I think I was moving things around and did this to satisfy the compiler at some point.

Once we've removed deprecatedCreateApplePayBackingCompletedError this should be a little simpler.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this around to better support Graph vs SCA errors 👍

@@ -12,7 +12,7 @@ public struct Checkout: Decodable {
}

public struct Backing: Decodable {
let requiresAction: Bool
let clientSecret: String?
public let requiresAction: Bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you alpha here?


public protocol StripeSCARequiring {
var requiresSCAFlow: Bool { get }
var clientSecret: String? { get }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here

public enum StripePaymentHandlerActionStatus {
case succeeded
case canceled
case failed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here too.

self.submitButtonTappedSignal.mapConst(true),
willUpdateApplePayBacking.mapConst(false)
let deprecatedCreateApplePayBackingCompletedError = deprecatedCreateApplePayBackingCompleted
.withLatest(from: deprecatedCreateApplePayBackingEvent.errors())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For curiosity, why are we using withLatest(from:) and not zip here? Shouldn't deprecatedCreateApplePayBackingCompletedError only emit when both deprecatedCreateApplePayBackingCompleted and deprecatedCreateApplePayBackingEvent.errors() emit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to avoid zip just because it can often cause sneaky problems, but that's possible true for this case.

I'll look into this along with #902 (comment).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is going to be necessary - #902 (comment).

Also we did withLatest(from:) here because the errors emit but we have a race condition between the Apple Pay sheet dismissing and the events emitting, which is why wait for deprecatedCreateApplePayBackingCompleted, which is a combined signal to emit and then take the most recently emitted errors.


let updateBackingEventErrors = updateBackingEvent.errors()
let createUpdateBackingEventErrors = Signal.merge(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you rename this to createUpdateBackingEventErrors to match the createOrUpdateBacking... convention adopted above?


let updateBackingEventErrors = updateBackingEvent.errors()
let createUpdateBackingEventErrors = Signal.merge(
deprecatedCreateApplePayBackingEvent.errors().map { $0 as Error },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's because self.showErrorBannerWithMessage merges different types of errors (GraphError and Error). What if here we simplify to:

let createUpdateBackingEventErrors = Signal.merge(
      deprecatedCreateApplePayBackingEvent.errors(),
      createBackingEvent.errors(),
      updateBackingEvent.errors()
    ).map { $0 as Error }


let updateApplePayBackingError = updateApplePayBackingCompleted
.withLatest(from: updateBackingEventErrors)
let createUpdateApplePayBackingError = createOrUpdateApplePayBackingCompleted
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you rename this variable too?

.map(second)

let updateBackingError = submitButtonTapped
.takePairWhen(updateBackingEventErrors)
let createUpdateBackingError = submitButtonTapped
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Contributor

@ifbarrera ifbarrera left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my comments are either minor changes or suggestions. Awesome work on this, I know it was tricky one! 🎉

@@ -443,6 +440,7 @@ public class PledgeViewModel: PledgeViewModelType, PledgeViewModelInputs, Pledge
createBackingEvent.filter { $0.isTerminating }.mapConst(true),
updateBackingEvent.filter { $0.isTerminating }.mapConst(true)
)
.skipRepeats()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 😄

@@ -459,90 +457,139 @@ public class PledgeViewModel: PledgeViewModelType, PledgeViewModelInputs, Pledge
updateButtonIsLoading
)

// MARK: - Success/Failure Create
let submitButtonTapped = Signal.merge(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming of this signal is a little misleading, because really what you're capturing here is whether a normal transaction has begun (to differentiate between a normal transaction and an apple pay transaction). I wonder if something like isCreateOrUpdateBacking would be more appropriate considering the usage below on line 508:

  let createOrUpdateBackingDidCompleteNoSCA = submitButtonTapped
      .takeWhen(createOrUpdateBackingEventValuesNoSCA)
      .filter(isTrue)
      .ignoreValues()

would become

  let createOrUpdateBackingDidCompleteNoSCA = isCreateOrUpdateBacking
      .takeWhen(createOrUpdateBackingEventValuesNoSCA)
      .filter(isTrue)
      .ignoreValues()

Just a thought.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call!

deprecatedCreateApplePayBackingEvent.values()
.combineLatest(with: deprecatedCreateApplePayBackingCompleted)
.ignoreValues(),
didCompleteApplePayBacking.combineLatest(with: willCreateApplePayBacking).ignoreValues(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a hunch that the combineLatest here isn't needed because createOrUpdateApplePayBackingCompleted zips with didInitiateApplePayBacking which merges with willCreateApplePayBacking (phew 😅). Fine to leave it if you want, but I would just try removing it and see if any tests break.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah naming is hard. In this particular case the combined signal is to differentiate between whether this updated or created an Apple Pay Backing 🤕 .

self.submitButtonEnabled.assertValues(
[false, false, false, false, true, false, true, false], "Amount and shipping rule unchanged"
)
self.submitButtonEnabled.assertValues([false, true, false, true, false], "Shipping rule changed")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the comment a little off here?

}
}

func testApplePayBackingFails_ThenSucceeds_SignalsDoNotOverlap() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is testing with the .update context. Can we update (see what I did there) the name of this test to reflect that?

self.goToThanks.assertDidNotEmitValue()
}
}

func testApplePayBackingFails_ThenStoredCardSucceeds_SignalsDoNotOverlap() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here the context seems to be .update.

Copy link
Contributor

@Scollaco Scollaco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well done on this one, Justin! 🎉 🎉

@justinswart justinswart changed the title [NT-286] SCA Support [NT-286][NT-360] SCA Support Oct 23, 2019
@justinswart justinswart merged commit b09379c into master Oct 23, 2019
@justinswart justinswart deleted the sca-support branch October 23, 2019 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants