diff --git a/paymentsheet/detekt-baseline.xml b/paymentsheet/detekt-baseline.xml index d305136d5bc..075560e5f83 100644 --- a/paymentsheet/detekt-baseline.xml +++ b/paymentsheet/detekt-baseline.xml @@ -85,6 +85,8 @@ NestedBlockDepth:SupportedPaymentMethodTest.kt$SupportedPaymentMethodTest$private fun generatePaymentIntentScenarios(): List<PaymentIntentTestInput> ReturnCount:AddressUtils.kt$internal fun CharSequence.levenshtein(other: CharSequence): Int ThrowsCount:PaymentSheetConfigurationKtx.kt$internal fun PaymentSheet.Configuration.validate() + TooManyFunctions:CustomerSheetEventReporter.kt$CustomerSheetEventReporter + TooManyFunctions:DefaultCustomerSheetEventReporter.kt$DefaultCustomerSheetEventReporter : CustomerSheetEventReporter TooManyFunctions:DefaultEventReporter.kt$DefaultEventReporter : EventReporter TooManyFunctions:DefaultFlowController.kt$DefaultFlowController : FlowController TooManyFunctions:EventReporter.kt$EventReporter diff --git a/paymentsheet/src/main/java/com/stripe/android/customersheet/CustomerSheetViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/customersheet/CustomerSheetViewModel.kt index 11f4924b6fd..fa4713d3c86 100644 --- a/paymentsheet/src/main/java/com/stripe/android/customersheet/CustomerSheetViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/customersheet/CustomerSheetViewModel.kt @@ -47,6 +47,7 @@ import com.stripe.android.paymentsheet.parseAppearance import com.stripe.android.paymentsheet.paymentdatacollection.FormArguments import com.stripe.android.paymentsheet.paymentdatacollection.ach.USBankAccountFormArguments import com.stripe.android.paymentsheet.state.toInternal +import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.ModifiableEditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.PaymentMethodRemovalDelayMillis import com.stripe.android.paymentsheet.ui.PrimaryButton @@ -330,6 +331,10 @@ internal class CustomerSheetViewModel @Inject constructor( ) } else { backStack.update { + it.last().eventReporterScreen?.let { screen -> + eventReporter.onScreenHidden(screen) + } + it.dropLast(1) } } @@ -456,6 +461,15 @@ internal class CustomerSheetViewModel @Inject constructor( ).onSuccess { updatedMethod -> onBackPressed() updatePaymentMethodInState(updatedMethod) + + eventReporter.onUpdatePaymentMethodSucceeded( + selectedBrand = brand + ) + }.onFailure { cause, _ -> + eventReporter.onUpdatePaymentMethodFailed( + selectedBrand = brand, + error = cause + ) } } @@ -512,6 +526,22 @@ internal class CustomerSheetViewModel @Inject constructor( to = CustomerSheetViewState.EditPaymentMethod( editPaymentMethodInteractor = editInteractorFactory.create( initialPaymentMethod = paymentMethod, + eventHandler = { event -> + when (event) { + is EditPaymentMethodViewInteractor.Event.ShowBrands -> { + eventReporter.onShowPaymentOptionBrands( + source = CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = event.brand + ) + } + is EditPaymentMethodViewInteractor.Event.HideBrands -> { + eventReporter.onHidePaymentOptionBrands( + source = CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = event.brand + ) + } + } + }, displayName = providePaymentMethodName(paymentMethod.type?.code), removeExecutor = { pm -> removePaymentMethod(pm).onSuccess { @@ -1137,6 +1167,8 @@ internal class CustomerSheetViewModel @Inject constructor( eventReporter.onScreenPresented(CustomerSheetEventReporter.Screen.AddPaymentMethod) is CustomerSheetViewState.SelectPaymentMethod -> eventReporter.onScreenPresented(CustomerSheetEventReporter.Screen.SelectPaymentMethod) + is CustomerSheetViewState.EditPaymentMethod -> + eventReporter.onScreenPresented(CustomerSheetEventReporter.Screen.EditPaymentMethod) else -> { } } @@ -1157,6 +1189,14 @@ internal class CustomerSheetViewModel @Inject constructor( } } + private val CustomerSheetViewState.eventReporterScreen: CustomerSheetEventReporter.Screen? + get() = when (this) { + is CustomerSheetViewState.AddPaymentMethod -> CustomerSheetEventReporter.Screen.AddPaymentMethod + is CustomerSheetViewState.SelectPaymentMethod -> CustomerSheetEventReporter.Screen.SelectPaymentMethod + is CustomerSheetViewState.EditPaymentMethod -> CustomerSheetEventReporter.Screen.EditPaymentMethod + else -> null + } + object Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") diff --git a/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEvent.kt b/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEvent.kt index 2243eaa199a..8cf52795b08 100644 --- a/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEvent.kt +++ b/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEvent.kt @@ -1,6 +1,7 @@ package com.stripe.android.customersheet.analytics import com.stripe.android.core.networking.AnalyticsEvent +import com.stripe.android.model.CardBrand internal sealed class CustomerSheetEvent : AnalyticsEvent { @@ -15,6 +16,21 @@ internal sealed class CustomerSheetEvent : AnalyticsEvent { CS_ADD_PAYMENT_METHOD_SCREEN_PRESENTED CustomerSheetEventReporter.Screen.SelectPaymentMethod -> CS_SELECT_PAYMENT_METHOD_SCREEN_PRESENTED + CustomerSheetEventReporter.Screen.EditPaymentMethod -> + CS_SHOW_EDITABLE_PAYMENT_OPTION + } + } + + class ScreenHidden( + screen: CustomerSheetEventReporter.Screen, + ) : CustomerSheetEvent() { + override val additionalParams: Map = mapOf() + override val eventName: String = when (screen) { + CustomerSheetEventReporter.Screen.EditPaymentMethod -> + CS_HIDE_EDITABLE_PAYMENT_OPTION + else -> throw IllegalArgumentException( + "${screen.name} has no supported event for hiding screen!" + ) } } @@ -85,6 +101,60 @@ internal sealed class CustomerSheetEvent : AnalyticsEvent { } } + class ShowPaymentOptionBrands( + source: Source, + selectedBrand: CardBrand + ) : CustomerSheetEvent() { + override val eventName: String = CS_SHOW_PAYMENT_OPTION_BRANDS + + override val additionalParams: Map = mapOf( + FIELD_CBC_EVENT_SOURCE to source.value, + FIELD_SELECTED_CARD_BRAND to selectedBrand.code + ) + + enum class Source(val value: String) { + Edit(VALUE_EDIT_CBC_EVENT_SOURCE), Add(VALUE_ADD_CBC_EVENT_SOURCE) + } + } + + class HidePaymentOptionBrands( + source: Source, + selectedBrand: CardBrand? + ) : CustomerSheetEvent() { + override val eventName: String = CS_HIDE_PAYMENT_OPTION_BRANDS + + override val additionalParams: Map = mapOf( + FIELD_CBC_EVENT_SOURCE to source.value, + FIELD_SELECTED_CARD_BRAND to selectedBrand?.code + ) + + enum class Source(val value: String) { + Edit(VALUE_EDIT_CBC_EVENT_SOURCE), Add(VALUE_ADD_CBC_EVENT_SOURCE) + } + } + + class UpdatePaymentOptionSucceeded( + selectedBrand: CardBrand, + ) : CustomerSheetEvent() { + override val eventName: String = CS_UPDATE_PAYMENT_METHOD + + override val additionalParams: Map = mapOf( + FIELD_SELECTED_CARD_BRAND to selectedBrand.code + ) + } + + class UpdatePaymentOptionFailed( + selectedBrand: CardBrand, + error: Throwable, + ) : CustomerSheetEvent() { + override val eventName: String = CS_UPDATE_PAYMENT_METHOD_FAILED + + override val additionalParams: Map = mapOf( + FIELD_SELECTED_CARD_BRAND to selectedBrand.code, + FIELD_ERROR_MESSAGE to error.message, + ) + } + internal companion object { const val CS_ADD_PAYMENT_METHOD_SCREEN_PRESENTED = "cs_add_payment_method_screen_presented" @@ -118,6 +188,21 @@ internal sealed class CustomerSheetEvent : AnalyticsEvent { const val CS_ADD_PAYMENT_METHOD_VIA_CREATE_ATTACH_FAILED = "cs_add_payment_method_via_createAttach_failure" + const val CS_SHOW_EDITABLE_PAYMENT_OPTION = "cs_open_edit_screen" + const val CS_HIDE_EDITABLE_PAYMENT_OPTION = "cs_cancel_edit_screen" + + const val CS_SHOW_PAYMENT_OPTION_BRANDS = "cs_open_cbc_dropdown" + const val CS_HIDE_PAYMENT_OPTION_BRANDS = "cs_close_cbc_dropdown" + + const val CS_UPDATE_PAYMENT_METHOD = "cs_update_card" + const val CS_UPDATE_PAYMENT_METHOD_FAILED = "cs_update_card_failed" + + const val FIELD_CBC_EVENT_SOURCE = "cbc_event_source" + const val FIELD_SELECTED_CARD_BRAND = "selected_card_brand" + const val FIELD_ERROR_MESSAGE = "error_message" const val FIELD_PAYMENT_METHOD_TYPE = "payment_method_type" + + const val VALUE_EDIT_CBC_EVENT_SOURCE = "edit" + const val VALUE_ADD_CBC_EVENT_SOURCE = "add" } } diff --git a/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEventReporter.kt b/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEventReporter.kt index f41c17e7012..007af15c5f6 100644 --- a/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEventReporter.kt +++ b/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/CustomerSheetEventReporter.kt @@ -1,5 +1,7 @@ package com.stripe.android.customersheet.analytics +import com.stripe.android.model.CardBrand + internal interface CustomerSheetEventReporter { /** @@ -7,6 +9,11 @@ internal interface CustomerSheetEventReporter { */ fun onScreenPresented(screen: Screen) + /** + * [Screen] was hidden to user + */ + fun onScreenHidden(screen: Screen) + /** * User attempted to confirm their saved payment method selection and succeeded */ @@ -52,13 +59,53 @@ internal interface CustomerSheetEventReporter { */ fun onAttachPaymentMethodFailed(style: AddPaymentMethodStyle) + /** + * User attempted to show payment option brands if payment + * option support card brand choice selection. + */ + fun onShowPaymentOptionBrands( + source: CardBrandChoiceEventSource, + selectedBrand: CardBrand, + ) + + /** + * User attempted to hide payment option brands if payment + * option support card brand choice selection. + */ + fun onHidePaymentOptionBrands( + source: CardBrandChoiceEventSource, + selectedBrand: CardBrand?, + ) + + /** + * User successfully updated the card brand choice selection for + * a payment method. + */ + fun onUpdatePaymentMethodSucceeded( + selectedBrand: CardBrand, + ) + + /** + * User failed to updated the card brand choice selection for + * a payment method. + */ + fun onUpdatePaymentMethodFailed( + selectedBrand: CardBrand, + error: Throwable, + ) + enum class Screen(val value: String) { AddPaymentMethod("add_payment_method"), SelectPaymentMethod("select_payment_method"), + EditPaymentMethod("edit_payment_method"), } enum class AddPaymentMethodStyle(val value: String) { SetupIntent("setup_intent"), CreateAttach("create_attach") } + + enum class CardBrandChoiceEventSource { + Add, Edit + } } diff --git a/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/DefaultCustomerSheetEventReporter.kt b/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/DefaultCustomerSheetEventReporter.kt index 80f8aa73e19..34070e94bdc 100644 --- a/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/DefaultCustomerSheetEventReporter.kt +++ b/paymentsheet/src/main/java/com/stripe/android/customersheet/analytics/DefaultCustomerSheetEventReporter.kt @@ -3,6 +3,7 @@ package com.stripe.android.customersheet.analytics import com.stripe.android.core.injection.IOContext import com.stripe.android.core.networking.AnalyticsRequestExecutor import com.stripe.android.core.networking.AnalyticsRequestFactory +import com.stripe.android.model.CardBrand import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -21,6 +22,19 @@ internal class DefaultCustomerSheetEventReporter @Inject constructor( ) } + override fun onScreenHidden(screen: CustomerSheetEventReporter.Screen) { + when (screen) { + CustomerSheetEventReporter.Screen.EditPaymentMethod -> { + fireEvent( + CustomerSheetEvent.ScreenHidden( + screen = screen + ) + ) + } + else -> Unit + } + } + override fun onConfirmPaymentMethodSucceeded(type: String) { fireEvent( CustomerSheetEvent.ConfirmPaymentMethodSucceeded( @@ -91,6 +105,62 @@ internal class DefaultCustomerSheetEventReporter @Inject constructor( ) } + override fun onShowPaymentOptionBrands( + source: CustomerSheetEventReporter.CardBrandChoiceEventSource, + selectedBrand: CardBrand + ) { + fireEvent( + CustomerSheetEvent.ShowPaymentOptionBrands( + source = when (source) { + CustomerSheetEventReporter.CardBrandChoiceEventSource.Add -> + CustomerSheetEvent.ShowPaymentOptionBrands.Source.Add + CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit -> + CustomerSheetEvent.ShowPaymentOptionBrands.Source.Edit + }, + selectedBrand = selectedBrand + ) + ) + } + + override fun onHidePaymentOptionBrands( + source: CustomerSheetEventReporter.CardBrandChoiceEventSource, + selectedBrand: CardBrand? + ) { + fireEvent( + CustomerSheetEvent.HidePaymentOptionBrands( + source = when (source) { + CustomerSheetEventReporter.CardBrandChoiceEventSource.Add -> + CustomerSheetEvent.HidePaymentOptionBrands.Source.Add + CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit -> + CustomerSheetEvent.HidePaymentOptionBrands.Source.Edit + }, + selectedBrand = selectedBrand + ) + ) + } + + override fun onUpdatePaymentMethodSucceeded( + selectedBrand: CardBrand, + ) { + fireEvent( + CustomerSheetEvent.UpdatePaymentOptionSucceeded( + selectedBrand = selectedBrand + ) + ) + } + + override fun onUpdatePaymentMethodFailed( + selectedBrand: CardBrand, + error: Throwable, + ) { + fireEvent( + CustomerSheetEvent.UpdatePaymentOptionFailed( + selectedBrand = selectedBrand, + error = error + ) + ) + } + private fun fireEvent(event: CustomerSheetEvent) { CoroutineScope(workContext).launch { analyticsRequestExecutor.executeAsync( diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt index 15c24479ce6..4d6ec7b8baa 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt @@ -3,6 +3,7 @@ package com.stripe.android.paymentsheet.analytics import com.stripe.android.core.injection.IOContext import com.stripe.android.core.networking.AnalyticsRequestExecutor import com.stripe.android.core.utils.DurationProvider +import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethodCode import com.stripe.android.networking.PaymentAnalyticsRequestFactory import com.stripe.android.paymentsheet.DeferredIntentConfirmationType @@ -215,6 +216,82 @@ internal class DefaultEventReporter @Inject internal constructor( ) } + override fun onShowEditablePaymentOption() { + fireEvent( + PaymentSheetEvent.ShowEditablePaymentOption( + isDeferred = isDeferred + ) + ) + } + + override fun onHideEditablePaymentOption() { + fireEvent( + PaymentSheetEvent.HideEditablePaymentOption( + isDeferred = isDeferred + ) + ) + } + + override fun onShowPaymentOptionBrands( + source: EventReporter.CardBrandChoiceEventSource, + selectedBrand: CardBrand + ) { + fireEvent( + PaymentSheetEvent.ShowPaymentOptionBrands( + selectedBrand = selectedBrand, + source = when (source) { + EventReporter.CardBrandChoiceEventSource.Add -> + PaymentSheetEvent.ShowPaymentOptionBrands.Source.Add + EventReporter.CardBrandChoiceEventSource.Edit -> + PaymentSheetEvent.ShowPaymentOptionBrands.Source.Edit + }, + isDeferred = isDeferred + ) + ) + } + + override fun onHidePaymentOptionBrands( + source: EventReporter.CardBrandChoiceEventSource, + selectedBrand: CardBrand? + ) { + fireEvent( + PaymentSheetEvent.HidePaymentOptionBrands( + selectedBrand = selectedBrand, + source = when (source) { + EventReporter.CardBrandChoiceEventSource.Add -> + PaymentSheetEvent.HidePaymentOptionBrands.Source.Add + EventReporter.CardBrandChoiceEventSource.Edit -> + PaymentSheetEvent.HidePaymentOptionBrands.Source.Edit + }, + isDeferred = isDeferred + ) + ) + } + + override fun onUpdatePaymentMethodSucceeded( + selectedBrand: CardBrand + ) { + fireEvent( + PaymentSheetEvent.UpdatePaymentOptionSucceeded( + selectedBrand = selectedBrand, + isDeferred = isDeferred + ) + ) + } + + override fun onUpdatePaymentMethodFailed( + selectedBrand: CardBrand, + error: Throwable, + ) { + fireEvent( + PaymentSheetEvent.UpdatePaymentOptionFailed( + selectedBrand = selectedBrand, + error = error, + isDeferred = isDeferred + ) + ) + } + private fun fireEvent(event: PaymentSheetEvent) { CoroutineScope(workContext).launch { analyticsRequestExecutor.executeAsync( diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt index ae69c4f6e23..90382af26a3 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt @@ -1,6 +1,7 @@ package com.stripe.android.paymentsheet.analytics import androidx.annotation.Keep +import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethodCode import com.stripe.android.paymentsheet.DeferredIntentConfirmationType import com.stripe.android.paymentsheet.PaymentSheet @@ -103,6 +104,48 @@ internal interface EventReporter { type: String, ) + /** + * The customer has chosen to show the edit screen for an editable payment option. + */ + fun onShowEditablePaymentOption() + + /** + * The customer has chosen to hide the edit screen for an editable payment option. + */ + fun onHideEditablePaymentOption() + + /** + * The customer has chosen to show the payment option brands for an editable payment option. + */ + fun onShowPaymentOptionBrands( + source: CardBrandChoiceEventSource, + selectedBrand: CardBrand, + ) + + /** + * The customer has chosen to hide the payment option brands for an editable payment option. The customer may + * have also chosen a new card brand selection as well. + */ + fun onHidePaymentOptionBrands( + source: CardBrandChoiceEventSource, + selectedBrand: CardBrand?, + ) + + /** + * The customer has successfully updated a payment method with a new card brand selection. + */ + fun onUpdatePaymentMethodSucceeded( + selectedBrand: CardBrand, + ) + + /** + * The customer has failed to update a payment method with a new card brand selection. + */ + fun onUpdatePaymentMethodFailed( + selectedBrand: CardBrand, + error: Throwable, + ) + enum class Mode(val code: String) { Complete("complete"), Custom("custom"); @@ -110,4 +153,8 @@ internal interface EventReporter { @Keep override fun toString(): String = code } + + enum class CardBrandChoiceEventSource { + Edit, Add + } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEvent.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEvent.kt index c5644592014..e20beae7460 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEvent.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEvent.kt @@ -1,6 +1,7 @@ package com.stripe.android.paymentsheet.analytics import com.stripe.android.core.networking.AnalyticsEvent +import com.stripe.android.model.CardBrand import com.stripe.android.paymentsheet.DeferredIntentConfirmationType import com.stripe.android.paymentsheet.PaymentSheet import com.stripe.android.paymentsheet.model.PaymentSelection @@ -140,6 +141,12 @@ internal sealed class PaymentSheetEvent : AnalyticsEvent { .name, ) + val preferredNetworks = configuration.preferredNetworks.takeIf { brands -> + brands.isNotEmpty() + }?.joinToString { brand -> + brand.code + } + @Suppress("DEPRECATION") val configurationMap = mapOf( FIELD_CUSTOMER to (configuration.customer != null), @@ -150,6 +157,7 @@ internal sealed class PaymentSheetEvent : AnalyticsEvent { FIELD_APPEARANCE to appearanceConfigMap, FIELD_BILLING_DETAILS_COLLECTION_CONFIGURATION to billingDetailsCollectionConfigMap, + FIELD_PREFERRED_NETWORKS to preferredNetworks ) return mapOf( FIELD_MOBILE_PAYMENT_ELEMENT_CONFIGURATION to configurationMap, @@ -303,6 +311,80 @@ internal sealed class PaymentSheetEvent : AnalyticsEvent { override val additionalParams: Map = emptyMap() } + class ShowEditablePaymentOption( + override val isDeferred: Boolean, + ) : PaymentSheetEvent() { + override val eventName: String = "mc_open_edit_screen" + + override val additionalParams: Map = emptyMap() + } + + class HideEditablePaymentOption( + override val isDeferred: Boolean, + ) : PaymentSheetEvent() { + override val eventName: String = "mc_cancel_edit_screen" + + override val additionalParams: Map = emptyMap() + } + + class ShowPaymentOptionBrands( + source: Source, + selectedBrand: CardBrand, + override val isDeferred: Boolean, + ) : PaymentSheetEvent() { + override val eventName: String = "mc_open_cbc_dropdown" + + override val additionalParams: Map = mapOf( + FIELD_CBC_EVENT_SOURCE to source.value, + FIELD_SELECTED_CARD_BRAND to selectedBrand.code + ) + + enum class Source(val value: String) { + Edit(VALUE_EDIT_CBC_EVENT_SOURCE), Add(VALUE_ADD_CBC_EVENT_SOURCE) + } + } + + class HidePaymentOptionBrands( + source: Source, + selectedBrand: CardBrand?, + override val isDeferred: Boolean, + ) : PaymentSheetEvent() { + override val eventName: String = "mc_close_cbc_dropdown" + + override val additionalParams: Map = mapOf( + FIELD_CBC_EVENT_SOURCE to source.value, + FIELD_SELECTED_CARD_BRAND to selectedBrand?.code + ) + + enum class Source(val value: String) { + Edit(VALUE_EDIT_CBC_EVENT_SOURCE), Add(VALUE_ADD_CBC_EVENT_SOURCE) + } + } + + class UpdatePaymentOptionSucceeded( + selectedBrand: CardBrand, + override val isDeferred: Boolean, + ) : PaymentSheetEvent() { + override val eventName: String = "mc_update_card" + + override val additionalParams: Map = mapOf( + FIELD_SELECTED_CARD_BRAND to selectedBrand.code + ) + } + + class UpdatePaymentOptionFailed( + selectedBrand: CardBrand, + error: Throwable, + override val isDeferred: Boolean, + ) : PaymentSheetEvent() { + override val eventName: String = "mc_update_card_failed" + + override val additionalParams: Map = mapOf( + FIELD_SELECTED_CARD_BRAND to selectedBrand.code, + FIELD_ERROR_MESSAGE to error.message, + ) + } + private fun standardParams( isDecoupled: Boolean, ): Map = mapOf( @@ -329,6 +411,7 @@ internal sealed class PaymentSheetEvent : AnalyticsEvent { const val FIELD_GOOGLE_PAY = "googlepay" const val FIELD_PRIMARY_BUTTON_COLOR = "primary_button_color" const val FIELD_BILLING = "default_billing_details" + const val FIELD_PREFERRED_NETWORKS = "preferred_networks" const val FIELD_DELAYED_PMS = "allows_delayed_payment_methods" const val FIELD_MOBILE_PAYMENT_ELEMENT_CONFIGURATION = "mpe_config" const val FIELD_APPEARANCE = "appearance" @@ -354,6 +437,11 @@ internal sealed class PaymentSheetEvent : AnalyticsEvent { const val FIELD_CURRENCY = "currency" const val FIELD_SELECTED_LPM = "selected_lpm" const val FIELD_ERROR_MESSAGE = "error_message" + const val FIELD_CBC_EVENT_SOURCE = "cbc_event_source" + const val FIELD_SELECTED_CARD_BRAND = "selected_card_brand" + + const val VALUE_EDIT_CBC_EVENT_SOURCE = "edit" + const val VALUE_ADD_CBC_EVENT_SOURCE = "add" } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethod.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethod.kt index 9c73ea570f5..4386ef924de 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethod.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethod.kt @@ -231,7 +231,11 @@ private fun Dropdown( Box( modifier = Modifier .clickable { - expanded = true + if (!expanded) { + expanded = true + + viewActionHandler.invoke(EditPaymentMethodViewAction.OnBrandChoiceOptionsShown) + } } .testTag(DROPDOWN_MENU_CLICKABLE_TEST_TAG) ) { @@ -271,6 +275,8 @@ private fun Dropdown( }, onDismiss = { expanded = false + + viewActionHandler.invoke(EditPaymentMethodViewAction.OnBrandChoiceOptionsDismissed) } ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewAction.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewAction.kt index b200e76b955..cde7db8bf88 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewAction.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewAction.kt @@ -1,6 +1,10 @@ package com.stripe.android.paymentsheet.ui internal sealed interface EditPaymentMethodViewAction { + object OnBrandChoiceOptionsShown : EditPaymentMethodViewAction + + object OnBrandChoiceOptionsDismissed : EditPaymentMethodViewAction + data class OnBrandChoiceChanged( val choice: EditPaymentMethodViewState.CardBrandChoice ) : EditPaymentMethodViewAction diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewInteractor.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewInteractor.kt index 6efda73e25f..0eb9509260e 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewInteractor.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditPaymentMethodViewInteractor.kt @@ -28,6 +28,12 @@ internal interface EditPaymentMethodViewInteractor { val viewState: StateFlow fun handleViewAction(viewAction: EditPaymentMethodViewAction) + + sealed interface Event { + data class ShowBrands(val brand: CardBrand) : Event + + data class HideBrands(val brand: CardBrand?) : Event + } } internal interface ModifiableEditPaymentMethodViewInteractor : EditPaymentMethodViewInteractor { @@ -36,6 +42,7 @@ internal interface ModifiableEditPaymentMethodViewInteractor : EditPaymentMethod interface Factory { fun create( initialPaymentMethod: PaymentMethod, + eventHandler: (EditPaymentMethodViewInteractor.Event) -> Unit, removeExecutor: PaymentMethodRemoveOperation, updateExecutor: PaymentMethodUpdateOperation, displayName: String, @@ -46,6 +53,7 @@ internal interface ModifiableEditPaymentMethodViewInteractor : EditPaymentMethod internal class DefaultEditPaymentMethodViewInteractor constructor( initialPaymentMethod: PaymentMethod, displayName: String, + private val eventHandler: (EditPaymentMethodViewInteractor.Event) -> Unit, private val removeExecutor: PaymentMethodRemoveOperation, private val updateExecutor: PaymentMethodUpdateOperation, workContext: CoroutineContext = Dispatchers.Default, @@ -97,6 +105,8 @@ internal class DefaultEditPaymentMethodViewInteractor constructor( is EditPaymentMethodViewAction.OnRemovePressed -> onRemovePressed() is EditPaymentMethodViewAction.OnRemoveConfirmed -> onRemoveConfirmed() is EditPaymentMethodViewAction.OnUpdatePressed -> onUpdatePressed() + is EditPaymentMethodViewAction.OnBrandChoiceOptionsShown -> onBrandChoiceOptionsShown() + is EditPaymentMethodViewAction.OnBrandChoiceOptionsDismissed -> onBrandChoiceOptionsDismissed() is EditPaymentMethodViewAction.OnBrandChoiceChanged -> onBrandChoiceChanged(viewAction.choice) is EditPaymentMethodViewAction.OnRemoveConfirmationDismissed -> onRemoveConfirmationDismissed() } @@ -147,8 +157,18 @@ internal class DefaultEditPaymentMethodViewInteractor constructor( } } + private fun onBrandChoiceOptionsShown() { + eventHandler(EditPaymentMethodViewInteractor.Event.ShowBrands(choice.value.brand)) + } + + private fun onBrandChoiceOptionsDismissed() { + eventHandler(EditPaymentMethodViewInteractor.Event.HideBrands(brand = null)) + } + private fun onBrandChoiceChanged(choice: EditPaymentMethodViewState.CardBrandChoice) { this.choice.value = choice + + eventHandler(EditPaymentMethodViewInteractor.Event.HideBrands(brand = choice.brand)) } private fun onRemoveConfirmationDismissed() { @@ -183,12 +203,14 @@ internal class DefaultEditPaymentMethodViewInteractor constructor( object Factory : ModifiableEditPaymentMethodViewInteractor.Factory { override fun create( initialPaymentMethod: PaymentMethod, + eventHandler: (EditPaymentMethodViewInteractor.Event) -> Unit, removeExecutor: PaymentMethodRemoveOperation, updateExecutor: PaymentMethodUpdateOperation, displayName: String, ): ModifiableEditPaymentMethodViewInteractor { return DefaultEditPaymentMethodViewInteractor( initialPaymentMethod = initialPaymentMethod, + eventHandler = eventHandler, removeExecutor = removeExecutor, updateExecutor = updateExecutor, displayName = displayName, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt index 870bd345888..584b956a971 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt @@ -38,6 +38,7 @@ import com.stripe.android.paymentsheet.repositories.CustomerRepository import com.stripe.android.paymentsheet.state.GooglePayState import com.stripe.android.paymentsheet.state.WalletsState import com.stripe.android.paymentsheet.toPaymentSelection +import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.HeaderTextFactory import com.stripe.android.paymentsheet.ui.ModifiableEditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.PaymentMethodRemovalDelayMillis @@ -267,7 +268,7 @@ internal abstract class BaseSheetViewModel( private fun reportPaymentSheetShown(currentScreen: PaymentSheetScreen) { when (currentScreen) { - is PaymentSheetScreen.Loading, AddAnotherPaymentMethod -> { + is PaymentSheetScreen.Loading, AddAnotherPaymentMethod, is PaymentSheetScreen.EditPaymentMethod -> { // Nothing to do here } is PaymentSheetScreen.SelectSavedPaymentMethods -> { @@ -276,8 +277,16 @@ internal abstract class BaseSheetViewModel( is AddFirstPaymentMethod -> { eventReporter.onShowNewPaymentOptionForm() } + } + } + + private fun reportPaymentSheetHidden(hiddenScreen: PaymentSheetScreen) { + when (hiddenScreen) { is PaymentSheetScreen.EditPaymentMethod -> { - // TODO(tillh-stripe) Add reporting + eventReporter.onHideEditablePaymentOption() + } + else -> { + // Events for hiding other screens not supported } } } @@ -447,10 +456,28 @@ internal abstract class BaseSheetViewModel( } fun modifyPaymentMethod(paymentMethod: PaymentMethod) { + eventReporter.onShowEditablePaymentOption() + transitionTo( PaymentSheetScreen.EditPaymentMethod( editInteractorFactory.create( initialPaymentMethod = paymentMethod, + eventHandler = { event -> + when (event) { + is EditPaymentMethodViewInteractor.Event.ShowBrands -> { + eventReporter.onShowPaymentOptionBrands( + source = EventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = event.brand + ) + } + is EditPaymentMethodViewInteractor.Event.HideBrands -> { + eventReporter.onHidePaymentOptionBrands( + source = EventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = event.brand + ) + } + } + }, displayName = providePaymentMethodName(paymentMethod.type?.code), removeExecutor = { method -> removePaymentMethodInEditScreen(method) @@ -510,6 +537,15 @@ internal abstract class BaseSheetViewModel( } handleBackPressed() + + eventReporter.onUpdatePaymentMethodSucceeded( + selectedBrand = brand + ) + }.onFailure { error -> + eventReporter.onUpdatePaymentMethodFailed( + selectedBrand = brand, + error = error, + ) } } @@ -568,7 +604,11 @@ internal abstract class BaseSheetViewModel( backStack.update { screens -> val modifiableScreens = screens.toMutableList() - modifiableScreens.removeLast().onClose() + val lastScreen = modifiableScreens.removeLast() + + lastScreen.onClose() + + reportPaymentSheetHidden(lastScreen) modifiableScreens.toList() } diff --git a/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetEventReporterTest.kt b/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetEventReporterTest.kt index 56ac1fcb2e8..4eb49ad1753 100644 --- a/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetEventReporterTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetEventReporterTest.kt @@ -12,6 +12,8 @@ import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.C import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_ADD_PAYMENT_METHOD_VIA_SETUP_INTENT_CANCELED import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_ADD_PAYMENT_METHOD_VIA_SETUP_INTENT_FAILED import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_ADD_PAYMENT_METHOD_VIA_SETUP_INTENT_SUCCEEDED +import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_HIDE_EDITABLE_PAYMENT_OPTION +import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_HIDE_PAYMENT_OPTION_BRANDS import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SELECT_PAYMENT_METHOD_CONFIRMED_SAVED_PM_FAILED import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SELECT_PAYMENT_METHOD_CONFIRMED_SAVED_PM_SUCCEEDED import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SELECT_PAYMENT_METHOD_DONE_TAPPED @@ -19,9 +21,14 @@ import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.C import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SELECT_PAYMENT_METHOD_REMOVE_PM_FAILED import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SELECT_PAYMENT_METHOD_REMOVE_PM_SUCCEEDED import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SELECT_PAYMENT_METHOD_SCREEN_PRESENTED +import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SHOW_EDITABLE_PAYMENT_OPTION +import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_SHOW_PAYMENT_OPTION_BRANDS +import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_UPDATE_PAYMENT_METHOD +import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.CS_UPDATE_PAYMENT_METHOD_FAILED import com.stripe.android.customersheet.analytics.CustomerSheetEvent.Companion.FIELD_PAYMENT_METHOD_TYPE import com.stripe.android.customersheet.analytics.CustomerSheetEventReporter import com.stripe.android.customersheet.analytics.DefaultCustomerSheetEventReporter +import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethod import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -68,6 +75,23 @@ class CustomerSheetEventReporterTest { req.params["event"] == CS_SELECT_PAYMENT_METHOD_SCREEN_PRESENTED } ) + + eventReporter.onScreenPresented(screen = CustomerSheetEventReporter.Screen.EditPaymentMethod) + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == CS_SHOW_EDITABLE_PAYMENT_OPTION + } + ) + } + + @Test + fun `onScreenHidden should fire analytics request with expected event value`() { + eventReporter.onScreenHidden(screen = CustomerSheetEventReporter.Screen.EditPaymentMethod) + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == CS_HIDE_EDITABLE_PAYMENT_OPTION + } + ) } @Test @@ -190,4 +214,66 @@ class CustomerSheetEventReporterTest { } ) } + + @Test + fun `onShowPaymentOptionBrands() should fire analytics request with expected event value`() { + eventReporter.onShowPaymentOptionBrands( + source = CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.Visa + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == CS_SHOW_PAYMENT_OPTION_BRANDS && + req.params["cbc_event_source"] == "edit" && + req.params["selected_card_brand"] == "visa" + } + ) + } + + @Test + fun `onHidePaymentOptionBrands() should fire analytics request with expected event value`() { + eventReporter.onHidePaymentOptionBrands( + source = CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.CartesBancaires, + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == CS_HIDE_PAYMENT_OPTION_BRANDS && + req.params["cbc_event_source"] == "edit" && + req.params["selected_card_brand"] == "cartes_bancaires" + } + ) + } + + @Test + fun `onUpdatePaymentMethodSucceeded() should fire analytics request with expected event value`() { + eventReporter.onUpdatePaymentMethodSucceeded( + selectedBrand = CardBrand.CartesBancaires, + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == CS_UPDATE_PAYMENT_METHOD && + req.params["selected_card_brand"] == "cartes_bancaires" + } + ) + } + + @Test + fun `onUpdatePaymentMethodFailed() should fire analytics request with expected event value`() { + eventReporter.onUpdatePaymentMethodFailed( + selectedBrand = CardBrand.CartesBancaires, + error = Exception("No network available!") + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == CS_UPDATE_PAYMENT_METHOD_FAILED && + req.params["selected_card_brand"] == "cartes_bancaires" && + req.params["error_message"] == "No network available!" + } + ) + } } diff --git a/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetScreenshotTest.kt b/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetScreenshotTest.kt index 3c5a2b2b601..b814f7874c9 100644 --- a/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetScreenshotTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetScreenshotTest.kt @@ -384,6 +384,7 @@ internal class CustomerSheetScreenshotTest { displayName = "Card", removeExecutor = { null }, updateExecutor = { pm, _ -> Result.success(pm) }, + eventHandler = {} ), isLiveMode = true, cbcEligibility = CardBrandChoiceEligibility.Eligible(preferredNetworks = emptyList()), diff --git a/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetViewModelTest.kt index 20bac8f8bba..b3b59e4afea 100644 --- a/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetViewModelTest.kt @@ -13,6 +13,7 @@ import com.stripe.android.customersheet.CustomerSheetViewState.SelectPaymentMeth import com.stripe.android.customersheet.analytics.CustomerSheetEventReporter import com.stripe.android.customersheet.injection.CustomerSheetViewModelModule import com.stripe.android.customersheet.utils.CustomerSheetTestHelper.addPaymentMethodViewState +import com.stripe.android.customersheet.utils.CustomerSheetTestHelper.createModifiableEditPaymentMethodViewInteractorFactory import com.stripe.android.customersheet.utils.CustomerSheetTestHelper.createViewModel import com.stripe.android.customersheet.utils.CustomerSheetTestHelper.selectPaymentMethodViewState import com.stripe.android.customersheet.utils.FakeCustomerSheetLoader @@ -22,6 +23,7 @@ import com.stripe.android.financialconnections.model.PaymentAccount import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodFixtures.CARD_PAYMENT_METHOD +import com.stripe.android.model.PaymentMethodFixtures.CARD_WITH_NETWORKS_PAYMENT_METHOD import com.stripe.android.model.PaymentMethodFixtures.US_BANK_ACCOUNT import com.stripe.android.model.PaymentMethodFixtures.US_BANK_ACCOUNT_VERIFIED import com.stripe.android.model.SetupIntentFixtures @@ -32,6 +34,7 @@ import com.stripe.android.paymentsheet.forms.FormFieldValues import com.stripe.android.paymentsheet.forms.FormViewModel import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.paymentdatacollection.ach.USBankAccountFormScreenState +import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewAction import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewAction.OnBrandChoiceChanged import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewAction.OnRemoveConfirmed import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewAction.OnRemovePressed @@ -52,6 +55,8 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.argThat +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -1264,6 +1269,40 @@ class CustomerSheetViewModelTest { verify(eventReporter).onScreenPresented(CustomerSheetEventReporter.Screen.AddPaymentMethod) } + @Test + fun `When edit payment screen is presented, event is reported`() { + val eventReporter: CustomerSheetEventReporter = mock() + + val viewModel = createViewModel( + workContext = testDispatcher, + eventReporter = eventReporter, + ) + + viewModel.handleViewAction( + CustomerSheetViewAction.OnModifyItem(paymentMethod = CARD_PAYMENT_METHOD) + ) + + verify(eventReporter).onScreenPresented(CustomerSheetEventReporter.Screen.EditPaymentMethod) + } + + @Test + fun `When edit payment screen is hidden, event is reported`() { + val eventReporter: CustomerSheetEventReporter = mock() + + val viewModel = createViewModel( + workContext = testDispatcher, + eventReporter = eventReporter, + ) + + viewModel.handleViewAction( + CustomerSheetViewAction.OnModifyItem(paymentMethod = CARD_PAYMENT_METHOD) + ) + + viewModel.handleViewAction(CustomerSheetViewAction.OnBackPressed) + + verify(eventReporter).onScreenHidden(CustomerSheetEventReporter.Screen.EditPaymentMethod) + } + @Test fun `When edit is tapped, event is reported`() = runTest(testDispatcher) { val eventReporter: CustomerSheetEventReporter = mock() @@ -2536,24 +2575,87 @@ class CustomerSheetViewModelTest { } @Test - fun `Updating payment method in edit screen goes through expected states`() = runTest(testDispatcher) { - val paymentMethods = PaymentMethodFactory.cards(size = 1) + fun `Updating payment method in edit screen goes through expected states & reports event`() = + runTest(testDispatcher) { + val eventReporter: CustomerSheetEventReporter = mock() + val paymentMethods = PaymentMethodFactory.cards(size = 1) - val firstMethod = paymentMethods.single() + val firstMethod = paymentMethods.single() - val updatedMethod = firstMethod.copy( - card = firstMethod.card?.copy( - networks = PaymentMethod.Card.Networks( - available = setOf("visa", "cartes_bancaires"), - preferred = "visa" + val updatedMethod = firstMethod.copy( + card = firstMethod.card?.copy( + networks = PaymentMethod.Card.Networks( + available = setOf("visa", "cartes_bancaires"), + preferred = "visa" + ) ) ) - ) + + val customerAdapter = FakeCustomerAdapter( + paymentMethods = CustomerAdapter.Result.Success(paymentMethods), + onUpdatePaymentMethod = { _, _ -> + CustomerAdapter.Result.Success(updatedMethod) + } + ) + + val viewModel = createViewModel( + workContext = testDispatcher, + initialBackStack = listOf( + selectPaymentMethodViewState.copy( + savedPaymentMethods = paymentMethods, + ) + ), + eventReporter = eventReporter, + customerPaymentMethods = paymentMethods, + customerAdapter = customerAdapter, + editInteractorFactory = createModifiableEditPaymentMethodViewInteractorFactory( + workContext = testDispatcher + ), + ) + + viewModel.viewState.test { + assertThat(awaitItem()).isInstanceOf(SelectPaymentMethod::class.java) + viewModel.handleViewAction(CustomerSheetViewAction.OnModifyItem(firstMethod)) + + val editViewState = awaitViewState() + editViewState.editPaymentMethodInteractor.handleViewAction( + OnBrandChoiceChanged( + EditPaymentMethodViewState.CardBrandChoice( + brand = CardBrand.Visa + ) + ) + ) + editViewState.editPaymentMethodInteractor.handleViewAction(OnUpdatePressed) + + // Confirm that nothing has changed yet. We're waiting to update the payment method + // once we return to the SPM screen. + val updatedViewState = awaitViewState() + assertThat(updatedViewState.savedPaymentMethods).containsExactlyElementsIn(paymentMethods) + + // Simulate the delay + testDispatcher.scheduler.advanceUntilIdle() + + verify(eventReporter).onUpdatePaymentMethodSucceeded(CardBrand.Visa) + + val finalViewState = awaitViewState() + assertThat(finalViewState.savedPaymentMethods).containsExactlyElementsIn(listOf(updatedMethod)) + } + } + + @Test + fun `Failed update payment method in edit screen reports event`() = runTest(testDispatcher) { + val eventReporter: CustomerSheetEventReporter = mock() + val paymentMethods = PaymentMethodFactory.cards(size = 1) + + val firstMethod = paymentMethods.single() val customerAdapter = FakeCustomerAdapter( paymentMethods = CustomerAdapter.Result.Success(paymentMethods), onUpdatePaymentMethod = { _, _ -> - CustomerAdapter.Result.Success(updatedMethod) + CustomerAdapter.Result.failure( + Exception("No network found!"), + "No network found!" + ) } ) @@ -2564,8 +2666,12 @@ class CustomerSheetViewModelTest { savedPaymentMethods = paymentMethods, ) ), + eventReporter = eventReporter, customerPaymentMethods = paymentMethods, customerAdapter = customerAdapter, + editInteractorFactory = createModifiableEditPaymentMethodViewInteractorFactory( + workContext = testDispatcher + ), ) viewModel.viewState.test { @@ -2582,16 +2688,113 @@ class CustomerSheetViewModelTest { ) editViewState.editPaymentMethodInteractor.handleViewAction(OnUpdatePressed) - // Confirm that nothing has changed yet. We're waiting to update the payment method - // once we return to the SPM screen. - val updatedViewState = awaitViewState() - assertThat(updatedViewState.savedPaymentMethods).containsExactlyElementsIn(paymentMethods) - // Simulate the delay testDispatcher.scheduler.advanceUntilIdle() - val finalViewState = awaitViewState() - assertThat(finalViewState.savedPaymentMethods).containsExactlyElementsIn(listOf(updatedMethod)) + verify(eventReporter).onUpdatePaymentMethodFailed( + eq(CardBrand.Visa), + argThat { + message == "No network found!" + } + ) + } + } + + @Test + fun `Showing payment option brands in edit screen reports event`() = runTest(testDispatcher) { + val eventReporter: CustomerSheetEventReporter = mock() + val paymentMethods = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + workContext = testDispatcher, + eventReporter = eventReporter, + initialBackStack = listOf( + selectPaymentMethodViewState.copy( + savedPaymentMethods = paymentMethods, + ) + ), + customerPaymentMethods = paymentMethods + ) + + viewModel.viewState.test { + assertThat(awaitItem()).isInstanceOf(SelectPaymentMethod::class.java) + viewModel.handleViewAction(CustomerSheetViewAction.OnModifyItem(paymentMethods.single())) + + val editViewState = awaitViewState() + editViewState.editPaymentMethodInteractor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceOptionsShown + ) + + verify(eventReporter).onShowPaymentOptionBrands( + source = CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.CartesBancaires + ) + } + } + + @Test + fun `Hiding payment option brands in edit screen reports event`() = runTest(testDispatcher) { + val eventReporter: CustomerSheetEventReporter = mock() + val paymentMethods = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + workContext = testDispatcher, + eventReporter = eventReporter, + initialBackStack = listOf( + selectPaymentMethodViewState.copy( + savedPaymentMethods = paymentMethods, + ) + ), + customerPaymentMethods = paymentMethods, + ) + + viewModel.viewState.test { + assertThat(awaitItem()).isInstanceOf(SelectPaymentMethod::class.java) + viewModel.handleViewAction(CustomerSheetViewAction.OnModifyItem(paymentMethods.single())) + + val editViewState = awaitViewState() + editViewState.editPaymentMethodInteractor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceOptionsDismissed + ) + + verify(eventReporter).onHidePaymentOptionBrands( + source = CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = null + ) + } + } + + @Test + fun `Changing payment option brand in edit screen reports event`() = runTest(testDispatcher) { + val eventReporter: CustomerSheetEventReporter = mock() + val paymentMethods = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + workContext = testDispatcher, + eventReporter = eventReporter, + initialBackStack = listOf( + selectPaymentMethodViewState.copy( + savedPaymentMethods = paymentMethods, + ) + ), + customerPaymentMethods = paymentMethods, + ) + + viewModel.viewState.test { + assertThat(awaitItem()).isInstanceOf(SelectPaymentMethod::class.java) + viewModel.handleViewAction(CustomerSheetViewAction.OnModifyItem(paymentMethods.single())) + + val editViewState = awaitViewState() + editViewState.editPaymentMethodInteractor.handleViewAction( + OnBrandChoiceChanged( + EditPaymentMethodViewState.CardBrandChoice(brand = CardBrand.Visa) + ) + ) + + verify(eventReporter).onHidePaymentOptionBrands( + source = CustomerSheetEventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.Visa + ) } } diff --git a/paymentsheet/src/test/java/com/stripe/android/customersheet/utils/CustomerSheetTestHelper.kt b/paymentsheet/src/test/java/com/stripe/android/customersheet/utils/CustomerSheetTestHelper.kt index ad22fe76820..556f170ddd8 100644 --- a/paymentsheet/src/test/java/com/stripe/android/customersheet/utils/CustomerSheetTestHelper.kt +++ b/paymentsheet/src/test/java/com/stripe/android/customersheet/utils/CustomerSheetTestHelper.kt @@ -32,6 +32,7 @@ import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.paymentdatacollection.FormArguments import com.stripe.android.paymentsheet.paymentdatacollection.ach.USBankAccountFormArguments import com.stripe.android.paymentsheet.ui.DefaultEditPaymentMethodViewInteractor +import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.ModifiableEditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.PaymentMethodRemoveOperation import com.stripe.android.paymentsheet.ui.PaymentMethodUpdateOperation @@ -42,6 +43,7 @@ import com.stripe.android.uicore.address.AddressRepository import com.stripe.android.utils.DummyActivityResultCaller import com.stripe.android.utils.FakeIntentConfirmationInterceptor import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -53,22 +55,6 @@ import kotlin.coroutines.EmptyCoroutineContext internal object CustomerSheetTestHelper { internal val application = ApplicationProvider.getApplicationContext() - private val editPaymentMethodInteractorFactory = object : ModifiableEditPaymentMethodViewInteractor.Factory { - override fun create( - initialPaymentMethod: PaymentMethod, - removeExecutor: PaymentMethodRemoveOperation, - updateExecutor: PaymentMethodUpdateOperation, - displayName: String - ): ModifiableEditPaymentMethodViewInteractor { - return DefaultEditPaymentMethodViewInteractor( - initialPaymentMethod = initialPaymentMethod, - displayName = "Card", - removeExecutor = removeExecutor, - updateExecutor = updateExecutor, - ) - } - } - internal val usBankAccountFormArguments = USBankAccountFormArguments( onBehalfOf = null, isCompleteFlow = false, @@ -221,7 +207,8 @@ internal object CustomerSheetTestHelper { paymentSelection = savedPaymentSelection, isGooglePayAvailable = isGooglePayAvailable, ), - editInteractorFactory: ModifiableEditPaymentMethodViewInteractor.Factory = editPaymentMethodInteractorFactory, + editInteractorFactory: ModifiableEditPaymentMethodViewInteractor.Factory = + createModifiableEditPaymentMethodViewInteractorFactory(), ): CustomerSheetViewModel { return CustomerSheetViewModel( application = application, @@ -258,4 +245,27 @@ internal object CustomerSheetTestHelper { registerFromActivity(DummyActivityResultCaller(), TestLifecycleOwner()) } } + + internal fun createModifiableEditPaymentMethodViewInteractorFactory( + workContext: CoroutineContext = StandardTestDispatcher(), + ): ModifiableEditPaymentMethodViewInteractor.Factory { + return object : ModifiableEditPaymentMethodViewInteractor.Factory { + override fun create( + initialPaymentMethod: PaymentMethod, + eventHandler: (EditPaymentMethodViewInteractor.Event) -> Unit, + removeExecutor: PaymentMethodRemoveOperation, + updateExecutor: PaymentMethodUpdateOperation, + displayName: String + ): ModifiableEditPaymentMethodViewInteractor { + return DefaultEditPaymentMethodViewInteractor( + initialPaymentMethod = initialPaymentMethod, + displayName = "Card", + removeExecutor = removeExecutor, + updateExecutor = updateExecutor, + eventHandler = eventHandler, + workContext = workContext + ) + } + } + } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FakeEditPaymentMethodInteractor.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FakeEditPaymentMethodInteractor.kt index 517ff0b9ff5..fd807d46242 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FakeEditPaymentMethodInteractor.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FakeEditPaymentMethodInteractor.kt @@ -3,6 +3,7 @@ package com.stripe.android.paymentsheet import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethod import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewAction +import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewState import com.stripe.android.paymentsheet.ui.ModifiableEditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.PaymentMethodRemoveOperation @@ -39,6 +40,7 @@ internal class FakeEditPaymentMethodInteractor( object Factory : ModifiableEditPaymentMethodViewInteractor.Factory { override fun create( initialPaymentMethod: PaymentMethod, + eventHandler: (EditPaymentMethodViewInteractor.Event) -> Unit, removeExecutor: PaymentMethodRemoveOperation, updateExecutor: PaymentMethodUpdateOperation, displayName: String diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt index 0f74ec11cff..9ecfbef2267 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt @@ -31,6 +31,7 @@ import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCreateParams import com.stripe.android.model.PaymentMethodCreateParamsFixtures import com.stripe.android.model.PaymentMethodFixtures +import com.stripe.android.model.PaymentMethodFixtures.CARD_WITH_NETWORKS_PAYMENT_METHOD import com.stripe.android.model.PaymentMethodFixtures.SEPA_DEBIT_PAYMENT_METHOD import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.model.PaymentMethodUpdateParams @@ -87,6 +88,7 @@ import org.junit.runner.RunWith import org.mockito.MockitoAnnotations import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn @@ -194,7 +196,105 @@ internal class PaymentSheetViewModelTest { } @Test - fun `modifyPaymentMethod updates payment methods on successful update`() = runTest { + fun `correct event is sent when dropdown is opened in EditPaymentMethod`() = runTest { + val paymentMethods = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + customerPaymentMethods = paymentMethods + ) + + viewModel.currentScreen.test { + awaitItem() + + viewModel.modifyPaymentMethod(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val currentScreen = awaitItem() + + assertThat(currentScreen).isInstanceOf(PaymentSheetScreen.EditPaymentMethod::class.java) + + if (currentScreen is PaymentSheetScreen.EditPaymentMethod) { + val interactor = currentScreen.interactor + + interactor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceOptionsShown + ) + + verify(eventReporter).onShowPaymentOptionBrands( + source = EventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.CartesBancaires + ) + } + } + } + + @Test + fun `correct event is sent when dropdown is dismissed in EditPaymentMethod`() = runTest { + val paymentMethods = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + customerPaymentMethods = paymentMethods + ) + + viewModel.currentScreen.test { + awaitItem() + + viewModel.modifyPaymentMethod(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val currentScreen = awaitItem() + + assertThat(currentScreen).isInstanceOf(PaymentSheetScreen.EditPaymentMethod::class.java) + + if (currentScreen is PaymentSheetScreen.EditPaymentMethod) { + val interactor = currentScreen.interactor + + interactor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceOptionsDismissed + ) + + verify(eventReporter).onHidePaymentOptionBrands( + source = EventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = null + ) + } + } + } + + @Test + fun `correct event is sent when dropdown is dismissed with change in EditPaymentMethod`() = runTest { + val paymentMethods = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + customerPaymentMethods = paymentMethods + ) + + viewModel.currentScreen.test { + awaitItem() + + viewModel.modifyPaymentMethod(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val currentScreen = awaitItem() + + assertThat(currentScreen).isInstanceOf(PaymentSheetScreen.EditPaymentMethod::class.java) + + if (currentScreen is PaymentSheetScreen.EditPaymentMethod) { + val interactor = currentScreen.interactor + + interactor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceChanged( + EditPaymentMethodViewState.CardBrandChoice(CardBrand.Visa) + ) + ) + + verify(eventReporter).onHidePaymentOptionBrands( + source = EventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.Visa + ) + } + } + } + + @Test + fun `modifyPaymentMethod updates payment methods and sends event on successful update`() = runTest { val paymentMethods = PaymentMethodFixtures.createCards(5) val firstPaymentMethod = paymentMethods.first() @@ -243,6 +343,8 @@ internal class PaymentSheetViewModelTest { assertThat(awaitItem()).isInstanceOf(SelectSavedPaymentMethods::class.java) } + verify(eventReporter).onUpdatePaymentMethodSucceeded(CardBrand.Visa) + val idCaptor = argumentCaptor() val paramsCaptor = argumentCaptor() @@ -269,6 +371,54 @@ internal class PaymentSheetViewModelTest { ) } + @Test + fun `modifyPaymentMethod sends event on failed update`() = runTest { + val paymentMethods = PaymentMethodFixtures.createCards(5) + + val firstPaymentMethod = paymentMethods.first() + + val customerRepository = spy( + FakeCustomerRepository( + onUpdatePaymentMethod = { + Result.failure(Exception("No network found!")) + } + ) + ) + val viewModel = createViewModel( + customerPaymentMethods = paymentMethods, + customerRepository = customerRepository + ) + + viewModel.currentScreen.test { + awaitItem() + + viewModel.modifyPaymentMethod(firstPaymentMethod) + + val currentScreen = awaitItem() + + assertThat(currentScreen).isInstanceOf(PaymentSheetScreen.EditPaymentMethod::class.java) + + if (currentScreen is PaymentSheetScreen.EditPaymentMethod) { + val interactor = currentScreen.interactor + + interactor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceChanged( + EditPaymentMethodViewState.CardBrandChoice(CardBrand.Visa) + ) + ) + + interactor.handleViewAction(EditPaymentMethodViewAction.OnUpdatePressed) + } + } + + verify(eventReporter).onUpdatePaymentMethodFailed( + selectedBrand = eq(CardBrand.Visa), + error = argThat { + message == "No network found!" + } + ) + } + @Test fun `checkout() should confirm saved card payment methods`() = runTest { val stripeIntent = PAYMENT_INTENT @@ -1173,6 +1323,35 @@ internal class PaymentSheetViewModelTest { verifyNoMoreInteractions(eventReporter) } + @Test + fun `Sends correct event when navigating to EditPaymentMethod screen`() = runTest { + val cards = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD, + customerPaymentMethods = cards, + ) + + viewModel.modifyPaymentMethod(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + verify(eventReporter).onShowEditablePaymentOption() + } + + @Test + fun `Sends correct event when navigating out of EditPaymentMethod screen`() = runTest { + val cards = listOf(CARD_WITH_NETWORKS_PAYMENT_METHOD) + + val viewModel = createViewModel( + stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD, + customerPaymentMethods = cards, + ) + + viewModel.modifyPaymentMethod(CARD_WITH_NETWORKS_PAYMENT_METHOD) + viewModel.handleBackPressed() + + verify(eventReporter).onHideEditablePaymentOption() + } + @Test fun `Sets editing to false when removing the last payment method while editing`() = runTest { val customerPaymentMethods = PaymentMethodFixtures.createCards(1) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt index da5d31e7434..b22c26763a4 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt @@ -7,6 +7,7 @@ import com.stripe.android.PaymentConfiguration import com.stripe.android.core.exception.APIException import com.stripe.android.core.networking.AnalyticsRequest import com.stripe.android.core.networking.AnalyticsRequestExecutor +import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethodFixtures import com.stripe.android.networking.PaymentAnalyticsRequestFactory import com.stripe.android.paymentsheet.PaymentSheet @@ -217,6 +218,114 @@ class DefaultEventReporterTest { ) } + @Test + fun `onShowEditablePaymentOption() should fire analytics request with expected event value`() { + val customEventReporter = createEventReporter(EventReporter.Mode.Custom) { + simulateSuccessfulSetup() + } + + customEventReporter.onShowEditablePaymentOption() + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == "mc_open_edit_screen" + } + ) + } + + @Test + fun `onHideEditablePaymentOption() should fire analytics request with expected event value`() { + val customEventReporter = createEventReporter(EventReporter.Mode.Custom) { + simulateSuccessfulSetup() + } + + customEventReporter.onHideEditablePaymentOption() + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == "mc_cancel_edit_screen" + } + ) + } + + @Test + fun `onShowPaymentOptionBrands() should fire analytics request with expected event value`() { + val customEventReporter = createEventReporter(EventReporter.Mode.Custom) { + simulateSuccessfulSetup() + } + + customEventReporter.onShowPaymentOptionBrands( + source = EventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.Visa + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == "mc_open_cbc_dropdown" && + req.params["cbc_event_source"] == "edit" && + req.params["selected_card_brand"] == "visa" + } + ) + } + + @Test + fun `onHidePaymentOptionBrands() should fire analytics request with expected event value`() { + val customEventReporter = createEventReporter(EventReporter.Mode.Custom) { + simulateSuccessfulSetup() + } + + customEventReporter.onHidePaymentOptionBrands( + source = EventReporter.CardBrandChoiceEventSource.Edit, + selectedBrand = CardBrand.CartesBancaires, + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == "mc_close_cbc_dropdown" && + req.params["cbc_event_source"] == "edit" && + req.params["selected_card_brand"] == "cartes_bancaires" + } + ) + } + + @Test + fun `onUpdatePaymentMethodSucceeded() should fire analytics request with expected event value`() { + val customEventReporter = createEventReporter(EventReporter.Mode.Custom) { + simulateSuccessfulSetup() + } + + customEventReporter.onUpdatePaymentMethodSucceeded( + selectedBrand = CardBrand.CartesBancaires, + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == "mc_update_card" && + req.params["selected_card_brand"] == "cartes_bancaires" + } + ) + } + + @Test + fun `onUpdatePaymentMethodFailed() should fire analytics request with expected event value`() { + val customEventReporter = createEventReporter(EventReporter.Mode.Custom) { + simulateSuccessfulSetup() + } + + customEventReporter.onUpdatePaymentMethodFailed( + selectedBrand = CardBrand.CartesBancaires, + error = Exception("No network available!") + ) + + verify(analyticsRequestExecutor).executeAsync( + argWhere { req -> + req.params["event"] == "mc_update_card_failed" && + req.params["selected_card_brand"] == "cartes_bancaires" && + req.params["error_message"] == "No network available!" + } + ) + } + @Test fun `constructor does not read from PaymentConfiguration`() { PaymentConfiguration.clearInstance() diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEventTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEventTest.kt index 39ee6bac2f5..824564309f4 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEventTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/PaymentSheetEventTest.kt @@ -3,6 +3,7 @@ package com.stripe.android.paymentsheet.analytics import com.google.common.truth.Truth.assertThat import com.stripe.android.core.exception.APIException import com.stripe.android.link.LinkPaymentDetails +import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentDetailsFixtures import com.stripe.android.model.PaymentMethodCreateParamsFixtures import com.stripe.android.model.PaymentMethodFixtures @@ -15,6 +16,7 @@ import kotlin.test.Test import kotlin.time.Duration.Companion.milliseconds @RunWith(RobolectricTestRunner::class) +@Suppress("LargeClass") class PaymentSheetEventTest { @Test @@ -60,6 +62,7 @@ class PaymentSheetEventTest { "phone" to "Automatic", "address" to "Automatic", ), + "preferred_networks" to null, ) assertThat(event.params).run { @@ -70,6 +73,60 @@ class PaymentSheetEventTest { @Test fun `Init event with minimum config should return expected params`() { + val event = PaymentSheetEvent.Init( + mode = EventReporter.Mode.Complete, + configuration = PaymentSheetFixtures.CONFIG_MINIMUM.copy( + preferredNetworks = listOf(CardBrand.CartesBancaires, CardBrand.Visa) + ), + isDeferred = false, + ) + + assertThat( + event.eventName + ).isEqualTo( + "mc_complete_init_default" + ) + + val expectedConfig = mapOf( + "customer" to false, + "googlepay" to false, + "primary_button_color" to false, + "default_billing_details" to false, + "allows_delayed_payment_methods" to false, + "appearance" to mapOf( + "colorsLight" to false, + "colorsDark" to false, + "corner_radius" to false, + "border_width" to false, + "font" to false, + "size_scale_factor" to false, + "primary_button" to mapOf( + "colorsLight" to false, + "colorsDark" to false, + "corner_radius" to false, + "border_width" to false, + "font" to false, + ), + "usage" to false, + ), + "billing_details_collection_configuration" to mapOf( + "attach_defaults" to false, + "name" to "Automatic", + "email" to "Automatic", + "phone" to "Automatic", + "address" to "Automatic", + ), + "preferred_networks" to "cartes_bancaires, visa", + ) + + assertThat(event.params).run { + containsEntry("is_decoupled", false) + containsEntry("mpe_config", expectedConfig) + } + } + + @Test + fun `Init event with preferred networks`() { val event = PaymentSheetEvent.Init( mode = EventReporter.Mode.Complete, configuration = PaymentSheetFixtures.CONFIG_MINIMUM, @@ -111,6 +168,7 @@ class PaymentSheetEventTest { "phone" to "Automatic", "address" to "Automatic", ), + "preferred_networks" to null, ) assertThat(event.params).run { @@ -455,6 +513,181 @@ class PaymentSheetEventTest { ) } + @Test + fun `ShowEditablePaymentOption event should return expected toString()`() { + val event = PaymentSheetEvent.ShowEditablePaymentOption( + isDeferred = false, + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_open_edit_screen", + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "is_decoupled" to false, + ) + ) + } + + @Test + fun `HideEditablePaymentOption event should return expected toString()`() { + val event = PaymentSheetEvent.HideEditablePaymentOption( + isDeferred = false, + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_cancel_edit_screen", + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "is_decoupled" to false, + ) + ) + } + + @Test + fun `ShowPaymentOptionBrands event with edit source should return expected toString()`() { + val event = PaymentSheetEvent.ShowPaymentOptionBrands( + selectedBrand = CardBrand.Visa, + source = PaymentSheetEvent.ShowPaymentOptionBrands.Source.Edit, + isDeferred = false, + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_open_cbc_dropdown" + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "cbc_event_source" to "edit", + "selected_card_brand" to "visa", + "is_decoupled" to false, + ) + ) + } + + @Test + fun `ShowPaymentOptionBrands event with add source should return expected toString()`() { + val event = PaymentSheetEvent.ShowPaymentOptionBrands( + selectedBrand = CardBrand.Visa, + source = PaymentSheetEvent.ShowPaymentOptionBrands.Source.Add, + isDeferred = false, + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_open_cbc_dropdown" + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "cbc_event_source" to "add", + "selected_card_brand" to "visa", + "is_decoupled" to false, + ) + ) + } + + @Test + fun `HidePaymentOptionBrands event with add source should return expected toString()`() { + val event = PaymentSheetEvent.HidePaymentOptionBrands( + selectedBrand = CardBrand.CartesBancaires, + source = PaymentSheetEvent.HidePaymentOptionBrands.Source.Add, + isDeferred = false, + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_close_cbc_dropdown" + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "cbc_event_source" to "add", + "selected_card_brand" to "cartes_bancaires", + "is_decoupled" to false, + ) + ) + } + + @Test + fun `HidePaymentOptionBrands event with edit source should return expected toString()`() { + val event = PaymentSheetEvent.HidePaymentOptionBrands( + selectedBrand = CardBrand.CartesBancaires, + source = PaymentSheetEvent.HidePaymentOptionBrands.Source.Edit, + isDeferred = false, + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_close_cbc_dropdown" + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "cbc_event_source" to "edit", + "selected_card_brand" to "cartes_bancaires", + "is_decoupled" to false, + ) + ) + } + + @Test + fun `UpdatePaymentOptionSucceeded event should return expected toString()`() { + val event = PaymentSheetEvent.UpdatePaymentOptionSucceeded( + selectedBrand = CardBrand.CartesBancaires, + isDeferred = false, + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_update_card" + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "selected_card_brand" to "cartes_bancaires", + "is_decoupled" to false, + ) + ) + } + + @Test + fun `UpdatePaymentOptionFailed event should return expected toString()`() { + val event = PaymentSheetEvent.UpdatePaymentOptionFailed( + selectedBrand = CardBrand.CartesBancaires, + error = Exception("No network available!"), + isDeferred = false, + + ) + assertThat( + event.eventName + ).isEqualTo( + "mc_update_card_failed" + ) + assertThat( + event.params + ).isEqualTo( + mapOf( + "selected_card_brand" to "cartes_bancaires", + "error_message" to "No network available!", + "is_decoupled" to false, + ) + ) + } + @Test fun `Init event should have default params if config is all defaults`() { val expectedPrimaryButton = mapOf( @@ -489,6 +722,7 @@ class PaymentSheetEventTest { "allows_delayed_payment_methods" to false, "appearance" to expectedAppearance, "billing_details_collection_configuration" to expectedBillingDetailsCollection, + "preferred_networks" to null, ) assertThat( PaymentSheetEvent.Init( @@ -538,6 +772,7 @@ class PaymentSheetEventTest { "allows_delayed_payment_methods" to true, "appearance" to expectedAppearance, "billing_details_collection_configuration" to expectedBillingDetailsCollection, + "preferred_networks" to null, ) assertThat( PaymentSheetEvent.Init( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditPaymentMethodViewInteractorTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditPaymentMethodViewInteractorTest.kt index 0e2039605e5..67cabd05690 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditPaymentMethodViewInteractorTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditPaymentMethodViewInteractorTest.kt @@ -46,31 +46,62 @@ class DefaultEditPaymentMethodViewInteractorTest { } @Test - fun `on selection changed, 'canUpdate' should be true & selected brand should be changed`() = runTest { - val interactor = createInteractor() + fun `on selection options shown, should report event specific to showing payment option brands`() = runTest { + val eventHandler = mock<(EditPaymentMethodViewInteractor.Event) -> Unit>() + val interactor = createInteractor(eventHandler = eventHandler) interactor.handleViewAction( - EditPaymentMethodViewAction.OnBrandChoiceChanged(VISA_BRAND_CHOICE) + EditPaymentMethodViewAction.OnBrandChoiceOptionsShown ) - interactor.viewState.test { - val state = awaitItem() + verify(eventHandler).invoke( + EditPaymentMethodViewInteractor.Event.ShowBrands(brand = CardBrand.CartesBancaires) + ) + } - assertThat(state).isEqualTo( - EditPaymentMethodViewState( - status = EditPaymentMethodViewState.Status.Idle, - last4 = "4242", - canUpdate = true, - selectedBrand = VISA_BRAND_CHOICE, - availableBrands = listOf( - VISA_BRAND_CHOICE, - CARTES_BANCAIRES_BRAND_CHOICE - ), - displayName = "Card", - ) + @Test + fun `on selection options hidden without change, should report event specific to hiding payment option brands`() = + runTest { + val eventHandler = mock<(EditPaymentMethodViewInteractor.Event) -> Unit>() + val interactor = createInteractor(eventHandler = eventHandler) + + interactor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceOptionsDismissed ) + + verify(eventHandler).invoke(EditPaymentMethodViewInteractor.Event.HideBrands(brand = null)) + } + + @Test + fun `on selection changed, should report event, 'canUpdate' should be true & selected brand should be changed`() = + runTest { + val eventHandler = mock<(EditPaymentMethodViewInteractor.Event) -> Unit>() + val interactor = createInteractor(eventHandler = eventHandler) + + interactor.handleViewAction( + EditPaymentMethodViewAction.OnBrandChoiceChanged(VISA_BRAND_CHOICE) + ) + + verify(eventHandler).invoke(EditPaymentMethodViewInteractor.Event.HideBrands(brand = CardBrand.Visa)) + + interactor.viewState.test { + val state = awaitItem() + + assertThat(state).isEqualTo( + EditPaymentMethodViewState( + status = EditPaymentMethodViewState.Status.Idle, + last4 = "4242", + canUpdate = true, + selectedBrand = VISA_BRAND_CHOICE, + availableBrands = listOf( + VISA_BRAND_CHOICE, + CARTES_BANCAIRES_BRAND_CHOICE + ), + displayName = "Card", + ) + ) + } } - } @Test fun `on selection changed & reverted, 'canUpdate' should be false`() = runTest { @@ -229,6 +260,7 @@ class DefaultEditPaymentMethodViewInteractorTest { } private fun createInteractor( + eventHandler: (EditPaymentMethodViewInteractor.Event) -> Unit = {}, onRemove: PaymentMethodRemoveOperation = { null }, onUpdate: PaymentMethodUpdateOperation = { _, _ -> Result.success(CARD_WITH_NETWORKS_PAYMENT_METHOD) }, workContext: CoroutineContext = UnconfinedTestDispatcher() @@ -236,6 +268,7 @@ class DefaultEditPaymentMethodViewInteractorTest { return DefaultEditPaymentMethodViewInteractor( initialPaymentMethod = CARD_WITH_NETWORKS_PAYMENT_METHOD, displayName = "Card", + eventHandler = eventHandler, removeExecutor = onRemove, updateExecutor = onUpdate, viewStateSharingStarted = SharingStarted.Eagerly, diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/FakeEditPaymentMethodInteractorFactory.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/FakeEditPaymentMethodInteractorFactory.kt index 0cbcfda9df9..b6ede46b2fd 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/FakeEditPaymentMethodInteractorFactory.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/utils/FakeEditPaymentMethodInteractorFactory.kt @@ -2,6 +2,7 @@ package com.stripe.android.paymentsheet.utils import com.stripe.android.model.PaymentMethod import com.stripe.android.paymentsheet.ui.DefaultEditPaymentMethodViewInteractor +import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.ModifiableEditPaymentMethodViewInteractor import com.stripe.android.paymentsheet.ui.PaymentMethodRemoveOperation import com.stripe.android.paymentsheet.ui.PaymentMethodUpdateOperation @@ -14,12 +15,14 @@ internal class FakeEditPaymentMethodInteractorFactory( ) : ModifiableEditPaymentMethodViewInteractor.Factory { override fun create( initialPaymentMethod: PaymentMethod, + eventHandler: (EditPaymentMethodViewInteractor.Event) -> Unit, removeExecutor: PaymentMethodRemoveOperation, updateExecutor: PaymentMethodUpdateOperation, displayName: String ): ModifiableEditPaymentMethodViewInteractor { return DefaultEditPaymentMethodViewInteractor( initialPaymentMethod = initialPaymentMethod, + eventHandler = eventHandler, removeExecutor = removeExecutor, updateExecutor = updateExecutor, displayName = displayName,