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

MBL-1488 & MBL-1360: Refactor Payment Sheet #2075

Merged
merged 8 commits into from
Jun 10, 2024

Conversation

amy-at-kickstarter
Copy link
Contributor

@amy-at-kickstarter amy-at-kickstarter commented May 31, 2024

📲 What

Refactor PledgePaymentMethodsViewController and PledgePaymentMethodsViewModel so that we always use a SetupIntent and store the card immediately, regardless of which pledge flow we're using.

🤔 Why

Late Pledges use a PaymentIntent in Stripe. When I implemented Late Pledges for the payment method sheet, I added a lot of plumbing to make the sheet use a PaymentIntent or a SetupIntent depending on context.

However, we have since learned that this behavior leads to bugs - PaymentIntent and SetupIntent lead to subtly different behavior with the payment sheet. For example, when you confirm a payment sheet made with a PaymentIntent it actually charges the card, ultimately leading to an issue with "dangling" Stripe charges that aren't attached to a pledge.

This refactor not only undoes all that, but greatly simplifies the payment sheet by making it always save a card and always returned a card that has been saved to the Kickstarter backend.

🛠 How

Each commit should be fairly atomic (although they can't be shipped separately as a functioning feature). I recommend reviewing this PR commit-by-commit.

✅ Acceptance criteria

I will run through all this acceptance criteria before merging this change.

Verify that adding a card in both pledge flow succeeds, the pledge is marked with the correct card on the KSR backend, and the card is saved to the KSR server:

  • Add a new card in the pledge flow, charge using that card
  • Add a new card in the pledge flow, select an existing card, charge using that card
  • Charge using an existing card in the pledge flow
  • Add a new card in the late pledge flow, charge using that card
  • Add a new card in the late pledge flow, select an existing card, charge using that card
  • Charge using an existing card in the late pledge flow
  • Charge using a 3DS challenge card in the live pledge flow - Worked but didn't ask for confirmation
  • Charge using a 3DS challenge card in the late pledge flow
  • Add a new card in the live pledge flow which is an ineligible card type for that pledge
  • Add a new card in the late pledge flow which is an ineligible card type for that pledge - Note that this fails and does not save the card, whereas live pledge flow DOES save the card in this case.

Verify that the late pledge "double charge" is fixed

  • Add a new card in the late pledge flow. Before hitting 'pledge', tap Back, then confirm again. You should be able to select a card and complete the pledge; you should not see an error

Verify context:

  • Verify that the setup intent pledge context is still set correctly for a live pledge
  • Verify that the setup intent pledge context is still set correctly for a late pledge

Verify that the payment sheet works correctly:

  • Able to select an existing card
  • Able to select a new card
  • Able to add multiple new cards

Verify that the payment sheet works correctly when modifying a pledge:

  • Change payment method for a pledge, adding a new card
  • Change payment method for a pledge, selecting an existing card
  • Previously selected payment method cannot be selected when modifying the pledge

Verify that manage card works correctly:

  • Manage card settings, add a new card
  • Manage card settings, delete a card

@amy-at-kickstarter amy-at-kickstarter force-pushed the feat/adyer/mbl-1488/clean-fix branch from 29f8aef to 6381fc5 Compare May 31, 2024 15:18
@nativeksr
Copy link
Collaborator

1 Warning
⚠️ Big PR

Generated by 🚫 Danger

@amy-at-kickstarter amy-at-kickstarter force-pushed the feat/adyer/mbl-1488/clean-fix branch 2 times, most recently from f14fa2b to 19ffab7 Compare May 31, 2024 20:42
@amy-at-kickstarter amy-at-kickstarter changed the title Feat/adyer/mbl 1488/clean fix MBL-1488, MBL-1360: Refactor Payment Sheet May 31, 2024
@amy-at-kickstarter amy-at-kickstarter force-pushed the feat/adyer/mbl-1488/clean-fix branch 3 times, most recently from 1d824e6 to ffd1eb6 Compare June 5, 2024 20:37
@amy-at-kickstarter amy-at-kickstarter changed the title MBL-1488, MBL-1360: Refactor Payment Sheet MBL-1488 & MBL-1360: Refactor Payment Sheet Jun 5, 2024
@amy-at-kickstarter amy-at-kickstarter force-pushed the feat/adyer/mbl-1488/clean-fix branch from ffd1eb6 to d169b98 Compare June 5, 2024 21:18
@amy-at-kickstarter amy-at-kickstarter requested a review from ifosli June 5, 2024 21:32
@amy-at-kickstarter amy-at-kickstarter self-assigned this Jun 5, 2024
@amy-at-kickstarter amy-at-kickstarter marked this pull request as ready for review June 5, 2024 21:32
strongSelf.viewModel.inputs
.paymentSheetDidAdd(newCard: paymentDisplayData, setupIntent: clientSecret)
.paymentSheetDidAdd(setupIntent: clientSecret)
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 data was already unused; PaymentMethodSettingsViewController never read paymentDisplayData. It just used the clientSecret.

@@ -49,7 +49,6 @@ final class PledgePaymentMethodsViewController: UIViewController {
|> ksr_addSubviewToParent()

self.tableView.registerCellClass(PledgePaymentMethodCell.self)
self.tableView.registerCellClass(PledgePaymentSheetPaymentMethodCell.self)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

n.B. this view controller still displays the list of payment methods in two sections, with the top section being 'new' cards and the bottom section being 'existing' cards. However, they use the same cell type and same backing data (a UserCreditCards.CreditCard).

@@ -80,8 +79,8 @@ final class PledgePaymentMethodsViewController: UIViewController {
.observeValues { [weak self] data in
guard let self = self else { return }

let cards = data.paymentMethodsCellData
let paymentSheetCards = data.paymentSheetPaymentMethodsCellData
let cards = data.existingPaymentMethods
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed these properties for (hopeful) clarity.

)
strongSelf.viewModel.inputs
.paymentSheetDidAdd(newCard: paymentDisplayData, clientSecret: clientSecret)
// Fetch the stripe payment method ID
Copy link
Contributor Author

@amy-at-kickstarter amy-at-kickstarter Jun 5, 2024

Choose a reason for hiding this comment

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

This is a somewhat annoying workaround. When the view model calls addPaymentSheetPaymentSource to store the credit card, the Kickstarter backend does not return a stripeCardId in the UserCreditCard.CreditCard in the payload. Payments has opened a ticket about this - but for now, we fetch the stripeCardId directly from the Stripe API, and pass it back up to the view model.

case .canceled:
// User cancelled intentionally so do nothing.
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.

huh, guess our linter strips break statements

@@ -51,9 +51,7 @@ final class PledgePaymentMethodsViewControllerTests: TestCase {
checkoutId: "checkoutId",
reward: reward,
context: .pledge,
refTag: nil,
pledgeTotal: Double.nan,
paymentSheetType: .setupIntent
Copy link
Contributor Author

Choose a reason for hiding this comment

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

n.B. that this stuff doesn't need to be passed around any more, because the payment sheet no longer needs a total or a type.

@@ -42,18 +42,18 @@ final class PledgePaymentMethodsDataSourceTests: XCTestCase {
func testLoad_PaymentSheetCardValues() {
let paymentSheetData = [
(
image: UIImage(),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Data source now uses the same data type for new cards and existing cards, as mentioned.

default:
break
}
self.viewModel.inputs.creditCardSelected(source: paymentSource)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now the payment sheet only returns ONE kind of payment source

Copy link
Contributor

Choose a reason for hiding this comment

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

Much nicer!

)

public static let discover = UserCreditCards.CreditCard(
expirationDate: "2022-03-12",
id: "5",
lastFour: "4242",
type: .discover
type: .discover,
stripeCardId: "pm_fake_discover"
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 need these or else some of my new asserts will get triggered.

@@ -86,25 +85,6 @@ public final class PaymentMethodSettingsViewModel: PaymentMethodsViewModelType,
)

let newSetupIntentCards = self.newSetupIntentCreditCardProperty.signal.skipNil()
.map { data -> PaymentSheetPaymentMethodCellData? in
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 was all unused!

@@ -127,7 +127,6 @@ internal final class PaymentMethodSettingsViewModelTests: TestCase {

self.vm.inputs
.paymentSheetDidAdd(
newCard: paymentOptionsDisplayData,
setupIntent: "seti_1LVlHO4VvJ2PtfhK43R6p7FI_secret_MEDiGbxfYVnHGsQy8v8TbZJTQhlNKLZ"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ifosli I hope these are test client secrets, otherwise we may have another thing to clean up 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

We should check on that with the payments team, just in case? My guess is that anything that's copy-pasted would've been copied from staging and I'd be surprised if the secrets are identical between prod and staging. Want me to check with them?

*/

public typealias StripePaymentMethodID = String
public typealias KSRCreditCardId = String
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just trying to make things clearer than case savedCreditCard(String, String?) which is what it really is.

} else {
assert(false, "Expected stripeCardId to be set. Late pledges may fail if this value is missing.")
updatePaymentMethodData
.selectedPaymentMethod = .savedCreditCard(newestPaymentSheetPaymentMethod.card.id, "")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

My goal was to make sure if stripeCardId failed anywhere, the code would still mostly work, since stripeCardId is only important for late pledges.

Copy link
Contributor

Choose a reason for hiding this comment

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

I like that! nit: I do think it'd be nice to pull this out into a helper function, though, considering we're doing basically the same checks in about 5 places.

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 suggestion, fixed!

let newSetupIntentCards = self.newStripeIntentCreditCardProperty.signal.skipNil()
.map { data -> [PaymentSheetPaymentMethodCellData] in
let (displayData, setupIntent) = data
let newlyAddedCardProducer = self.newStripeIntentCreditCardProperty.signal.skipNil()
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 is the beefiest part of the change - when a new card is selected, add it to the Kickstarter backend, then populate the payment sheet with the actual UserCreditCards.CreditCard returned by that mutation.

return card
}
.skipNil()
.materialize()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

One slightly annoying thing is now this extra call means the spinner on the new payment method button goes away before the new card appears. I struggled to figure out how to plumb together the reloading code to make that loading button work. Thoughts/suggestions? I don't think it's a blocking issue, but would be a nicer experience.

Copy link
Contributor

Choose a reason for hiding this comment

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

File a followup ticket? If I remember correctly, we're telling the spinner to hide when payment sheet dismisses. We should be able to do that when we get a result from newCards instead, but we'd need to handle the cancellation case the same as we do now, so it might get too complicated to be worth doing. I don't have any useful suggestions, at least not without digging into the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do.

requiresConfirmation: true
)
}

// MARK: - Validate New Cards

let validateCheckoutNewCardInput = Signal.combineLatest(checkoutId, selectedCard)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All of this can go away, because all cards are now "existing" cards (i.e. stored to the KSR backend)

@amy-at-kickstarter amy-at-kickstarter force-pushed the feat/adyer/mbl-1488/clean-fix branch from d169b98 to 7f7092c Compare June 6, 2024 15:37
@@ -126,15 +126,20 @@ public final class PledgePaymentMethodsViewModel: PledgePaymentMethodsViewModelT
.materialize()
}

let allNewlyAddedCards: Signal<[UserCreditCards.CreditCard], Never> = newlyAddedCardProducer.values()
.scan(into: []) { array, card in
array.insert(card, at: 0)
Copy link
Contributor Author

@amy-at-kickstarter amy-at-kickstarter Jun 6, 2024

Choose a reason for hiding this comment

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

I forgot the scan at first - this turns a signal like [a, b, c] into a signal like [[a], [b, a], [c, b, a]].

@amy-at-kickstarter amy-at-kickstarter force-pushed the feat/adyer/mbl-1488/clean-fix branch from 7f7092c to e7dad22 Compare June 6, 2024 15:43
Copy link
Contributor

@ifosli ifosli left a comment

Choose a reason for hiding this comment

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

I've only reviewed 4/7 commits (hence the "comment" instead of "approve") but I'll get to the rest on monday, but since you're probably starting work before me I figured I'd give you something to work with if you'd like. So far I think it looks good - just some nits that you're welcome to ignore if you disagree.

return card
}
.skipNil()
.materialize()
Copy link
Contributor

Choose a reason for hiding this comment

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

File a followup ticket? If I remember correctly, we're telling the spinner to hide when payment sheet dismisses. We should be able to do that when we get a result from newCards instead, but we'd need to handle the cancellation case the same as we do now, so it might get too complicated to be worth doing. I don't have any useful suggestions, at least not without digging into the code.

@@ -519,45 +509,49 @@ private func pledgePaymentSheetMethodCellDataAndSelectedCardSetupIntent(
}()

// First ensure all existing payment sheet payment methods are not selected.
let preexistingPaymentSheetCardDataUnselected: [PaymentSheetPaymentMethodCellData] = {
var updatedPaymentSheetPaymentMethodData = paymentMethodData.paymentSheetPaymentMethodsCellData
let preexistingPaymentSheetCardDataUnselected: [PledgePaymentMethodCellData] = {
Copy link
Contributor

Choose a reason for hiding this comment

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

If I understand this correctly, can we rename this to newCardDataUnselected instead of preexistingPaymentSheetCardDataUnselected?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do!

paymentSheetCard?.card.id,
addPaymentSheetResponse.createPaymentSource.paymentSource.id
)
XCTAssertFalse(paymentSheetCard?.isEnabled ?? false)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: since this is within a test, I think I'd rather use paymentSheetCard!.isEnabled to make sure it's false; defaulting to false if the card is nil and asserting that the bool is false seems like a bit of a dangerous pattern. Same applies to the next line

default:
break
}
self.viewModel.inputs.creditCardSelected(source: paymentSource)
Copy link
Contributor

Choose a reason for hiding this comment

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

Much nicer!

// User cancelled intentionally so do nothing.
break
strongSelf.viewModel.inputs.shouldCancelPaymentSheetAppearance(state: true)
// User cancelled intentionally so do nothing.
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Indentation for this comment looks off

@@ -127,7 +127,6 @@ internal final class PaymentMethodSettingsViewModelTests: TestCase {

self.vm.inputs
.paymentSheetDidAdd(
newCard: paymentOptionsDisplayData,
setupIntent: "seti_1LVlHO4VvJ2PtfhK43R6p7FI_secret_MEDiGbxfYVnHGsQy8v8TbZJTQhlNKLZ"
Copy link
Contributor

Choose a reason for hiding this comment

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

We should check on that with the payments team, just in case? My guess is that anything that's copy-pasted would've been copied from staging and I'd be surprised if the secrets are identical between prod and staging. Want me to check with them?

}

// There is a backend bug where the stripeCardId isn't being refreshed
// after the add card mutation is called. This adds it back in.
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: It'd be nice to link to a ticket here so we can check if the bug still exists the next time we touch this class, especially if we want to remove this code.

if let stripeCardId = selectedCard?.stripeCardId {
selectedPaymentMethod = .savedCreditCard(selectedCardId, stripeCardId)
} else {
assert(false, "Expected stripeCardId to be set. Late pledges may fail if this value is missing.")
Copy link
Contributor

Choose a reason for hiding this comment

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

Just confirming: this assert only crashes debug builds? Would we want to log this as an error to firebase for release builds? Up to you if you think that's worth doing or not. (Probably out of scope for this, but I think it'd be nice to have an assert helper function that crashed debug builds and logged to firebase for beta/release builds.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To the best of my knowledge, yes - we have assertions disabled at the compiler level for production builds:
Screenshot 2024-06-10 at 11 54 18 AM

I loooove the idea of something that has both an assertion and a log to firebase! Let me think about that.

} else {
assert(false, "Expected stripeCardId to be set. Late pledges may fail if this value is missing.")
updatePaymentMethodData
.selectedPaymentMethod = .savedCreditCard(newestPaymentSheetPaymentMethod.card.id, "")
Copy link
Contributor

Choose a reason for hiding this comment

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

I like that! nit: I do think it'd be nice to pull this out into a helper function, though, considering we're doing basically the same checks in about 5 places.

.switchMap { checkoutId, selectedCard, paymentIntentClientSecret in

assert(
selectedCard.stripePaymentMethodId != nil,
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Maybe check that the string is non-empty here instead of just non-nil (just in case we default to empty string ever in the future)?

Copy link
Contributor

@ifosli ifosli left a comment

Choose a reason for hiding this comment

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

Looked through the last 3 commits and they look good!

@amy-at-kickstarter amy-at-kickstarter force-pushed the feat/adyer/mbl-1488/clean-fix branch from 0fa7ce2 to e09039d Compare June 10, 2024 18:13
@amy-at-kickstarter amy-at-kickstarter merged commit ea4fb98 into main Jun 10, 2024
5 checks passed
@amy-at-kickstarter amy-at-kickstarter deleted the feat/adyer/mbl-1488/clean-fix branch June 10, 2024 19:22
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.

3 participants