-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Conversation
29f8aef
to
6381fc5
Compare
Generated by 🚫 Danger |
f14fa2b
to
19ffab7
Compare
1d824e6
to
ffd1eb6
Compare
ffd1eb6
to
d169b98
Compare
strongSelf.viewModel.inputs | ||
.paymentSheetDidAdd(newCard: paymentDisplayData, setupIntent: clientSecret) | ||
.paymentSheetDidAdd(setupIntent: clientSecret) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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 😅
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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, "") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)
d169b98
to
7f7092c
Compare
@@ -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) |
There was a problem hiding this comment.
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]]
.
…aymentSourceSelected Completely removes the possibility of Pledge PaymentMethodsViewController returning a SetupIntent or PaymentIntent
…er to Pledge/LatePledge controllers
7f7092c
to
e7dad22
Compare
There was a problem hiding this 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() |
There was a problem hiding this comment.
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] = { |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.") |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
} else { | ||
assert(false, "Expected stripeCardId to be set. Late pledges may fail if this value is missing.") | ||
updatePaymentMethodData | ||
.selectedPaymentMethod = .savedCreditCard(newestPaymentSheetPaymentMethod.card.id, "") |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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)?
There was a problem hiding this 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!
0fa7ce2
to
e09039d
Compare
📲 What
Refactor
PledgePaymentMethodsViewController
andPledgePaymentMethodsViewModel
so that we always use aSetupIntent
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 aPaymentIntent
or aSetupIntent
depending on context.However, we have since learned that this behavior leads to bugs -
PaymentIntent
andSetupIntent
lead to subtly different behavior with the payment sheet. For example, when you confirm a payment sheet made with aPaymentIntent
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:
Verify that the late pledge "double charge" is fixed
Verify context:
Verify that the payment sheet works correctly:
Verify that the payment sheet works correctly when modifying a pledge:
Verify that manage card works correctly: