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

Read the default_payment_method field from elements session and use that as the default PM #4313

Merged
merged 45 commits into from
Dec 9, 2024

Conversation

joyceqin-stripe
Copy link
Collaborator

@joyceqin-stripe joyceqin-stripe commented Nov 27, 2024

Summary

Added allowsSetAsDefaultPM flag. Only shown if we're using customer session.
If allowsSetAsDefaultPM is on, then instead of reading from the locally stored default, we read from ElementsSession.

Motivation

MOBILESDK-2799

Testing

customer_session, allowsSetAsDefaultPM on
Hardcoded the value that was "read" from default_payment_method (set to the 4242 Visa stripe id)
After changing the selected payment method and paying with it, upon reload the PaymentSheet default is back to our hardcoded default.

Simulator.Screen.Recording.-.iPhone.16.-.2024-12-02.at.08.05.00.mp4

Changelog

Copy link

github-actions bot commented Nov 27, 2024

🚨 New dead code detected in this PR:

String+Localized.swift:352 warning: Property 'remove_payment_method' is unused
SavedPaymentMethodCollectionView.swift:113 warning: Property 'cbcEligible' is assigned, but never used
SavedPaymentMethodRowButton.swift:16 warning: Function 'didSelectRemoveButton(_:with:)' is unused
SavedPaymentMethodRowButton.swift:70 warning: Property 'canRemove' is unused
SavedPaymentMethodRowButton.swift:117 warning: Function 'handleRemoveButtonTapped()' is unused
VerticalSavedPaymentMethodsViewController.swift:99 warning: Property 'hasCoBrandedCards' is unused
VerticalSavedPaymentMethodsViewController.swift:312 warning: Function 'didSelectRemoveButton(_:with:)' is unused
PaymentSheetUIKitAdditions.swift:164 warning: Function 'makeRowButtonContentStackView(arrangedSubviews:)' is unused

Please remove the dead code before merging.

If this is intentional, you can bypass this check by adding the label skip dead code check to this PR.

ℹ️ If this comment appears to be left in error, double check that the flagged code is actually used and/or make sure your branch is up-to-date with master.

@joyceqin-stripe joyceqin-stripe marked this pull request as ready for review December 2, 2024 16:52
@joyceqin-stripe joyceqin-stripe requested review from a team as code owners December 2, 2024 16:52
Copy link
Collaborator

@porter-stripe porter-stripe left a comment

Choose a reason for hiding this comment

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

There is a lot of duplicated logic that is very similar, we should try to consolidate that into one place as much as possible.

@@ -53,6 +53,9 @@ struct CustomerSheetTestPlayground: View {
SettingPickerView(setting: $playgroundController.settings.paymentMethodRemove)
SettingPickerView(setting: $playgroundController.settings.paymentMethodRemoveLast)
SettingPickerView(setting: $playgroundController.settings.paymentMethodAllowRedisplayFilters)
if playgroundController.settings.alternateUpdatePaymentMethodNavigation == .on {
Copy link
Collaborator

Choose a reason for hiding this comment

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

When can we remove alternateUpdatePaymentMethodNavigation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I suppose if we're trying to release this is the default flow, it should be removed now

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I do anticipate possibly having to update a lot of tests though, so might be changing a lot of files

Comment on lines 39 to 40
// to test default payment methods reading from back end, hard-code a valid default payment method
// later, when API calls to get and update default payment method are available, that will no longer be needed
Copy link
Collaborator

Choose a reason for hiding this comment

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

This comment doesn't make much sense to me, I don't see any code changes near this comment indicating we are hard coding a default PM.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

What I was trying to say is that the only way I knew to test it was to add a payment method, get its stripe ID, and then hardcode that here so that the elements session will set that as its default. And then after adding other cards, even after paying with them, when the payment sheet is reloaded, it will have the hardcoded default selected, which shows that it's reading from the elements session instead of the local default.

Right now, there's a duping process where when you read from "default_payment_method", it returns the locally saved default that you pass into the API call— it's not actually retrieving a default from the back end. When the API endpoints are ready, then we should be able to actually get the value stored in the back end, but until then, the only way I could think to test that it's reading from the elementsSession value instead of the local default was to hard-code a default (e.g. let defaultPaymentMethod = "pm_1QSOUILu5o3P18ZpN4PY49SE")

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hrm, I see what you mean, but don't think we need a comment for that. I don't think anyone else will need this comment as it shouldn't be relevant once this feature is implemented. If you want to leave it let's add a TODO to remove it for yourself.

@@ -107,7 +107,13 @@ extension CustomerSessionAdapter {
return stripePaymentMethodId
}

func fetchSelectedPaymentOption(for customerId: String) -> CustomerPaymentOption? {
func fetchSelectedPaymentOption(for customerId: String, elementsSession: STPElementsSession? = nil) -> CustomerPaymentOption? {
if configuration.allowsSetAsDefaultPM,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit; I think a guard here is more appropriate as it enforces that you return a value:

guard configuration.allowsSetAsDefaultPM,
      let elementsSession = elementsSession,
      let customer = elementsSession.customer,
      let defaultPaymentMethod = customer.defaultPaymentMethod else {
    return CustomerPaymentOption.defaultPaymentMethod(for: customerId)
}

return CustomerPaymentOption.stripeId(defaultPaymentMethod)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, it seems unnecessary to pass in the entire STPElementsSession here, we only really need the customer.

@@ -86,6 +86,11 @@ extension CustomerSheet {
/// If false (default), only card brand choice eligible cards can be edited and users can remove payment methods from the list screen.
@_spi(AlternateUpdatePaymentMethodNavigation) public var alternateUpdatePaymentMethodNavigation = false

/// This is an experimental feature that may be removed at any time.
/// If true, users can set a payment method as default.
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should add that this also influences where we read the default payment method from, not just allow setting.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

What about "users can set a payment method as default and sync their default payment method across web and mobile"


var selectedPaymentOption: CustomerPaymentOption?

// read from back end
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this comment could be better!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Changed it to "get default payment method from elements session"— is that better?

Comment on lines 139 to 142
/// This is an experimental feature that may be removed at any time.
/// If true, users can set a payment method as default.
/// If false (default), users cannot set default payment methods.
@_spi(AllowsSetAsDefaultPM) public var allowsSetAsDefaultPM = false
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same comment from about in the CustomerSheetConfiguration. Also same in PaymentSheetConfiguration.

Comment on lines 40 to 51
// read from back end
if configuration.allowsSetAsDefaultPM,
let customer = loadResult.elementsSession.customer {
let defaultPaymentMethod = customer.paymentMethods.filter {
$0.stripeId == customer.defaultPaymentMethod
}.first
if let defaultPaymentMethod = defaultPaymentMethod {
return .saved(paymentMethod: defaultPaymentMethod)
}

}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Somewhat similar comment to above, about the "read from back end" comment and the nested if's

Maybe:

if configuration.allowsSetAsDefaultPM,
   let customer = loadResult.elementsSession.customer,
   let defaultPaymentMethodId = customer.defaultPaymentMethod,
   let defaultPaymentMethod = customer.paymentMethods.first(where: { $0.stripeId == defaultPaymentMethodId }) {
    return .saved(paymentMethod: defaultPaymentMethod)
}

Comment on lines 322 to 324
// read from back end
if configuration.allowsSetAsDefaultPM,
let customer = elementsSession.customer {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Similar here w/ the comment and nested if's and multiple branches leading to the same CustomerPaymentOption.defaultPaymentMethod(for: customerID). I think there's a cleaner way to write this.

@@ -438,9 +444,22 @@ class SavedPaymentOptionsViewController: UIViewController {

/// Creates the list of viewmodels to display in the "saved payment methods" carousel e.g. `["+ Add", "Apple Pay", "Link", "Visa 4242"]`
/// - Returns defaultSelectedIndex: The index of the view model that is the default e.g. in the above list, if "Visa 4242" is the default, the index is 3.
static func makeViewModels(savedPaymentMethods: [STPPaymentMethod], customerID: String?, showApplePay: Bool, showLink: Bool) -> (defaultSelectedIndex: Int, viewModels: [Selection]) {
static func makeViewModels(savedPaymentMethods: [STPPaymentMethod], customerID: String?, showApplePay: Bool, showLink: Bool, allowsSetAsDefaultPM: Bool, elementsSession: STPElementsSession) -> (defaultSelectedIndex: Int, viewModels: [Selection]) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Similar to how I don't think we need to pass in the entire ElementsSession, maybe just the customer or default PM. That way we can consolidate all this unwrapping/duplicate code.

Comment on lines 291 to 300
// read from back end
if configuration.allowsSetAsDefaultPM,
let customer = elementsSession.customer {
let defaultPaymentMethod = customer.paymentMethods.filter {
$0.stripeId == customer.defaultPaymentMethod
}.first
if let defaultPaymentMethod = defaultPaymentMethod {
return .saved(paymentMethod: defaultPaymentMethod)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This code is duplicated in a few spots, I think we should consolidate this logic into one spot and pass around the defaultPM or something.

@joyceqin-stripe
Copy link
Collaborator Author

There is a lot of duplicated logic that is very similar, we should try to consolidate that into one place as much as possible.

I've made a static func getDefaultPaymentMethod(from customer: ElementsCustomer?) -> STPPaymentMethod? in ElementsCustomer.swift

@joyceqin-stripe
Copy link
Collaborator Author

Okay, I've extracted reading the default payment method from the elements to a function in ElementsCustomer, edited some of the comments, and changed some parameters from ElementsSession to ElementsCustomer

Copy link
Collaborator

@porter-stripe porter-stripe left a comment

Choose a reason for hiding this comment

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

Let's make sure we are also adding tests for this new functionality.

Comment on lines 39 to 40
// to test default payment methods reading from back end, hard-code a valid default payment method
// later, when API calls to get and update default payment method are available, that will no longer be needed
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hrm, I see what you mean, but don't think we need a comment for that. I don't think anyone else will need this comment as it shouldn't be relevant once this feature is implemented. If you want to leave it let's add a TODO to remove it for yourself.

Comment on lines 44 to 48

static func getDefaultPaymentMethod(from customer: ElementsCustomer?) -> STPPaymentMethod? {
guard let customer = customer else { return nil }
return customer.paymentMethods.first { $0.stripeId == customer.defaultPaymentMethod }
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why static? Why not an instance function on ElementsCustomer with tests?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I can't really remember any reasoning of mine, you're right that it should be an instance function on ElementsCustomer

Comment on lines 111 to 117
guard configuration.allowsSetAsDefaultPM,
let customer = customer,
let defaultPaymentMethod = customer.defaultPaymentMethod else {
return CustomerPaymentOption.defaultPaymentMethod(for: customerId)
}

return CustomerPaymentOption.stripeId(defaultPaymentMethod)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This behavior seems inconsistent with other places where we read the default payment method. If the user is opted into allowsSetAsDefaultPM but the customer does not have a default payment method we will fall through and read it from the stripeId on line 117. In other places we do not fall through. My understanding is that if we are opted into this feature we will use the customer default payment method as the single source of truth for their default. Let's add tests here too.

Correct me if I'm wrong.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not sure why I used guard here instead of if/else because it seems more confusing here, but in this scenario, if the user is opted in but the customer does not have a default, it reads from defaultPaymentMethod on line 114, which returns the local default. I saw the thread with Bella where she said that it should fall back to first saved payment method in the list instead, so I will have to change this to reflect that

Comment on lines 40 to 45
// get default payment method from elements session
if configuration.allowsSetAsDefaultPM,
let defaultPaymentMethod = ElementsCustomer.getDefaultPaymentMethod(from: loadResult.elementsSession.customer) {
return .saved(paymentMethod: defaultPaymentMethod)
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems to be in too high in the function, we should use previousPaymentOption instead of the default on the customer. See line 63 where we try to read the customer default.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You're right, I didn't consider that the view shouldn't automatically initially select the default because there could have been previous user input. I'll fix that

Comment on lines 291 to 295
// get default payment method from elements session
if configuration.allowsSetAsDefaultPM,
let defaultPaymentMethod = ElementsCustomer.getDefaultPaymentMethod(from: elementsSession.customer) {
return .saved(paymentMethod: defaultPaymentMethod)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same comment here as above about being too high in the function, it should not take precedence over previousPaymentOption. See below for where we look for the saved PM.

Copy link
Collaborator

@porter-stripe porter-stripe left a comment

Choose a reason for hiding this comment

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

Almost there, one small clean up

Comment on lines 63 to 65
else {
customerDefault = nil
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should be able to remove this, customerDefault is implied nil from it's definition. Same with other similar code paths in the PR.

@joyceqin-stripe joyceqin-stripe enabled auto-merge (squash) December 9, 2024 23:02
@joyceqin-stripe joyceqin-stripe merged commit 171edb3 into master Dec 9, 2024
6 checks passed
@joyceqin-stripe joyceqin-stripe deleted the joyceqin-MOBILESDK-2799 branch December 9, 2024 23: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.

2 participants