Skip to content

Commit

Permalink
Improve failed payment "retry" handling
Browse files Browse the repository at this point in the history
  • Loading branch information
samiuelson committed Dec 6, 2024
1 parent cf8d030 commit 5b12dd8
Show file tree
Hide file tree
Showing 12 changed files with 179 additions and 93 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ sealed class ChildToParentEvent {
data object PaymentProcessing : ChildToParentEvent()
data object PaymentFailed : ChildToParentEvent()
data object RetryFailedPaymentClicked : ChildToParentEvent()
data object ExitOrderAfterFailedTransactionClicked : ChildToParentEvent()
data object GoBackToCheckoutAfterFailedPayment : ChildToParentEvent()
data object OrderSuccessfullyPaid : ChildToParentEvent()
data object ExitPosClicked : ChildToParentEvent()
data object ProductsDialogInfoIconClicked : ChildToParentEvent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ sealed class ParentToChildrenEvent {
) : ParentToChildrenEvent()
data class CheckoutClicked(val productIds: List<Long>) : ParentToChildrenEvent()
data object OrderSuccessfullyPaid : ParentToChildrenEvent()
data object OrderCardPaymentAborted : ParentToChildrenEvent()
}

interface WooPosParentToChildrenEventReceiver {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,30 +106,28 @@ class WooPosHomeViewModel @Inject constructor(
)
}

is ChildToParentEvent.ExitOrderAfterFailedTransactionClicked -> {
_state.value = _state.value.copy(
screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible
)
sendEventToChildren(ParentToChildrenEvent.OrderCardPaymentAborted)
}
is ChildToParentEvent.NewTransactionClicked -> {
_state.value = _state.value.copy(
screenPositionState = WooPosHomeState.ScreenPositionState.Cart.Visible
)
sendEventToChildren(ParentToChildrenEvent.OrderSuccessfullyPaid)
}

is ChildToParentEvent.PaymentProcessing,
is ChildToParentEvent.OrderSuccessfullyPaid,
is ChildToParentEvent.PaymentFailed -> {
_state.value = _state.value.copy(
screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.FullScreenTotals
)
}

is ChildToParentEvent.GoBackToCheckoutAfterFailedPayment,
is ChildToParentEvent.RetryFailedPaymentClicked -> {
_state.value = _state.value.copy(
screenPositionState = WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals
)
}

ChildToParentEvent.ExitPosClicked -> {
_state.value = _state.value.copy(
exitConfirmationDialog = WooPosHomeState.ExitConfirmationDialog(isVisible = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,7 @@ class WooPosCartViewModel @Inject constructor(

is ParentToChildrenEvent.ItemClickedInProductSelector -> handleItemClickedInProductSelector(event)

is ParentToChildrenEvent.OrderSuccessfullyPaid,
is ParentToChildrenEvent.OrderCardPaymentAborted -> clearCart()
is ParentToChildrenEvent.OrderSuccessfullyPaid -> clearCart()

is ParentToChildrenEvent.CheckoutClicked -> Unit
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ package com.woocommerce.android.ui.woopos.home.totals
sealed class WooPosTotalsUIEvent {
data object OnNewTransactionClicked : WooPosTotalsUIEvent()
data object RetryFailedTransactionClicked : WooPosTotalsUIEvent()
data object ExitOrderAfterFailedTransactionClicked : WooPosTotalsUIEvent()
data object GoBackToCheckoutAfterFailedPayment : WooPosTotalsUIEvent()
data object RetryOrderCreationClicked : WooPosTotalsUIEvent()
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,24 +151,43 @@ class WooPosTotalsViewModel @Inject constructor(
is WooPosTotalsUIEvent.RetryOrderCreationClicked -> {
createOrderDraft(dataState.value.productIds)
}
WooPosTotalsUIEvent.ExitOrderAfterFailedTransactionClicked -> viewModelScope.launch {
childrenToParentEventSender.sendToParent(ChildToParentEvent.ExitOrderAfterFailedTransactionClicked)
WooPosTotalsUIEvent.GoBackToCheckoutAfterFailedPayment -> viewModelScope.launch {
childrenToParentEventSender.sendToParent(ChildToParentEvent.GoBackToCheckoutAfterFailedPayment)
retryPaymentCollectionFromScratch()
}
WooPosTotalsUIEvent.RetryFailedTransactionClicked -> viewModelScope.launch {
cancelPaymentAction()
childrenToParentEventSender.sendToParent(ChildToParentEvent.RetryFailedPaymentClicked)
val order = totalsRepository.getOrderById(dataState.value.orderId)
if (order == null) {
uiState.value = InitialState
childrenToParentEventSender.sendToParent(ChildToParentEvent.BackFromCheckoutToCartClicked)
} else {
uiState.value = buildWooPosTotalsViewState(order)
collectPayment()
val paymentState = cardReaderPaymentController?.paymentState?.value
check(paymentState != null) {
"Retry failed transaction clicked but payment controller is null"
}
check(paymentState is CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment) {
"Retry failed transaction clicked but payment state is not PaymentFailed"
}
when {
paymentState.onRetry != null -> {
paymentState.onRetry!!()
}
else -> {
childrenToParentEventSender.sendToParent(ChildToParentEvent.RetryFailedPaymentClicked)
retryPaymentCollectionFromScratch()
}
}
}
}
}

private suspend fun retryPaymentCollectionFromScratch() {
cancelPaymentAction()
val order = totalsRepository.getOrderById(dataState.value.orderId)
if (order == null) {
uiState.value = InitialState
childrenToParentEventSender.sendToParent(ChildToParentEvent.BackFromCheckoutToCartClicked)
} else {
uiState.value = buildWooPosTotalsViewState(order)
collectPayment()
}
}

private fun collectPayment() {
if (!networkStatus.isConnected()) {
viewModelScope.launch {
Expand Down Expand Up @@ -203,7 +222,6 @@ class WooPosTotalsViewModel @Inject constructor(
}

is ParentToChildrenEvent.ItemClickedInProductSelector,
ParentToChildrenEvent.OrderCardPaymentAborted,
ParentToChildrenEvent.OrderSuccessfullyPaid -> Unit
}
}
Expand All @@ -222,25 +240,28 @@ class WooPosTotalsViewModel @Inject constructor(

when (paymentState) {
is CardReaderPaymentState.CollectingPayment,
is CardReaderPaymentState.LoadingData -> {
}
is CardReaderPaymentState.LoadingData -> {}

is CardReaderPaymentState.ProcessingPayment,
is CardReaderPaymentState.PaymentCapturing,
CardReaderPaymentState.ReFetchingOrder -> {
uiState.value = buildPaymentProcessingState()
childrenToParentEventSender.sendToParent(ChildToParentEvent.PaymentProcessing)
}

is CardReaderPaymentState.PaymentSuccessful -> {
uiState.value =
PaymentSuccess(
orderTotalText = paymentState.amountWithCurrencyLabel
)
childrenToParentEventSender.sendToParent(ChildToParentEvent.OrderSuccessfullyPaid)
}

is CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment -> {
uiState.value = buildPaymentFailedState(paymentState)
childrenToParentEventSender.sendToParent(ChildToParentEvent.PaymentFailed)
}

is CardReaderPaymentOrRefundState.CardReaderInteracRefundState,
is CardReaderPaymentState.PaymentFailed.BuiltInReaderFailedPayment,
is CardReaderPaymentState.PrintingReceipt,
Expand All @@ -254,12 +275,22 @@ class WooPosTotalsViewModel @Inject constructor(

private fun buildPaymentFailedState(
state: CardReaderPaymentState.PaymentFailed.ExternalReaderFailedPayment
): PaymentFailed = PaymentFailed(
title = resourceProvider.getString(
R.string.woopos_success_totals_payment_failed_title
),
subtitle = uiStringParser.asString(state.errorType.message)
)
): PaymentFailed {
val isRetryAvailable = state.onRetry != null
val retryButtonLabel = if (isRetryAvailable) {
resourceProvider.getString(R.string.woo_pos_payment_failed_try_again)
} else {
resourceProvider.getString(R.string.woo_pos_payment_failed_try_another_payment_method)
}
return PaymentFailed(
title = resourceProvider.getString(
R.string.woopos_success_totals_payment_failed_title
),
subtitle = uiStringParser.asString(state.errorType.message),
retryPaymentButtonLabel = retryButtonLabel,
isReturnToCheckoutButtonVisible = isRetryAvailable
)
}

private fun buildPaymentProcessingState(): PaymentProcessing = PaymentProcessing(
title = resourceProvider.getString(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ sealed class WooPosTotalsViewState : Parcelable {
data class PaymentFailed(
val title: String,
val subtitle: String,
val retryPaymentButtonLabel: String,
val isReturnToCheckoutButtonVisible: Boolean = false,
) : WooPosTotalsViewState()

data class PaymentSuccess(var orderTotalText: String) : WooPosTotalsViewState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,27 @@ fun WooPosPaymentFailedScreen(
)
Spacer(modifier = Modifier.height(40.dp.toAdaptivePadding()))
WooPosButton(
text = stringResource(R.string.woo_pos_payment_failed_try_another_payment_method),
text = state.retryPaymentButtonLabel,
modifier = Modifier
.height(80.dp)
.width(604.dp)
) { onUIEvent(WooPosTotalsUIEvent.RetryFailedTransactionClicked) }
Spacer(modifier = Modifier.height(24.dp.toAdaptivePadding()))
WooPosOutlinedButton(
modifier = Modifier
.height(80.dp)
.width(604.dp),
content = {
Text(
color = MaterialTheme.colors.primary,
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold,
text = stringResource(R.string.woo_pos_payment_failed_exit_order)
)
}
) { onUIEvent(WooPosTotalsUIEvent.ExitOrderAfterFailedTransactionClicked) }
if (state.isReturnToCheckoutButtonVisible) {
Spacer(modifier = Modifier.height(24.dp.toAdaptivePadding()))
WooPosOutlinedButton(
modifier = Modifier
.height(80.dp)
.width(604.dp),
content = {
Text(
color = MaterialTheme.colors.primary,
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold,
text = stringResource(R.string.woo_pos_payment_failed_go_back_to_checkout)
)
}
) { onUIEvent(WooPosTotalsUIEvent.GoBackToCheckoutAfterFailedPayment) }
}
Spacer(modifier = Modifier.height(80.dp.toAdaptivePadding()))
}
}
Expand All @@ -91,6 +93,8 @@ fun WooPosPaymentFailedScreenPreview() {
state = WooPosTotalsViewState.PaymentFailed(
title = "Payment failed",
subtitle = "Unfortunately, this payment has been declined.",
retryPaymentButtonLabel = "Try again",
isReturnToCheckoutButtonVisible = true,
),
onUIEvent = {}
)
Expand Down
6 changes: 3 additions & 3 deletions WooCommerce/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4304,7 +4304,9 @@
<string name="woopos_success_totals_payment_processing_title">Processing payment</string>
<string name="woopos_success_totals_payment_processing_subtitle">Please wait…</string>
<string name="woopos_success_totals_payment_failed_title">Payment failed</string>
<string name="woopos_success_totals_payment_failed_subtitle">Unfortunately, this payment has been declined.</string>
<string name="woo_pos_payment_failed_try_another_payment_method">Try another payment method</string>
<string name="woo_pos_payment_failed_try_again">Try payment again</string>
<string name="woo_pos_payment_failed_go_back_to_checkout">Exit order</string>

<string name="woopos_floating_toolbar_overlay_menu_content_description">Dimmed background. Tap to close the menu.</string>
<string name="woopos_floating_toolbar_card_reader_connected_status_content_description">Card reader connected</string>
Expand Down Expand Up @@ -4390,6 +4392,4 @@
<string name="woo_shipping_labels_package_creation_box_type">Box</string>
<string name="woo_shipping_labels_package_creation_envelope_type">Envelope</string>
<string name="email_not_registered_wpcom">Hmm, we can\'t find a WordPress.com account connected to this email address.</string>
<string name="woo_pos_payment_failed_try_another_payment_method">Try another payment method</string>
<string name="woo_pos_payment_failed_exit_order">Exit order</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ class WooPosHomeViewModelTest {
}

@Test
fun `given home screen is at checkout, when exit order clicked after failed payment, then should show cart`() = runTest {
fun `given home screen is at checkout, when go back to checkout clicked after failed payment, then should show cart with totals`() = runTest {
// GIVEN
val events = MutableSharedFlow<ChildToParentEvent>()
whenever(childrenToParentEventReceiver.events).thenReturn(events)
Expand All @@ -286,12 +286,12 @@ class WooPosHomeViewModelTest {
).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals)

// WHEN
events.emit(ChildToParentEvent.ExitOrderAfterFailedTransactionClicked)
events.emit(ChildToParentEvent.GoBackToCheckoutAfterFailedPayment)

// THEN
assertThat(
viewModel.state.value.screenPositionState
).isEqualTo(WooPosHomeState.ScreenPositionState.Cart.Visible)
).isEqualTo(WooPosHomeState.ScreenPositionState.Checkout.CartWithTotals)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,37 +513,6 @@ class WooPosCartViewModelTest {
assertThat(finalItem.isAppearanceAnimationPlayed).isTrue
}

@Test
fun `given non-empty cart, when card payment is aborted, then should clear the cart`() = runTest {
// GIVEN
val product = ProductTestUtils.generateProduct(
productId = 23L,
productName = "title",
amount = "10.0"
).copy(firstImageUrl = "url")

val parentToChildrenEventsMutableFlow = MutableSharedFlow<ParentToChildrenEvent>()
whenever(parentToChildrenEventReceiver.events).thenReturn(parentToChildrenEventsMutableFlow)
whenever(getProductById(eq(product.remoteId))).thenReturn(product)
val sut = createSut()
val states = sut.state.captureValues()

parentToChildrenEventsMutableFlow.emit(
ParentToChildrenEvent.ItemClickedInProductSelector(
WooPosItemsViewModel.ItemClickedData.SimpleProduct(id = product.remoteId)
)
)

// WHEN
parentToChildrenEventsMutableFlow.emit(ParentToChildrenEvent.OrderCardPaymentAborted)

// THEN
val toolbar = states.last().toolbar
assertThat(toolbar.backIconVisible).isFalse()
assertThat(toolbar.itemsCount).isNull()
assertThat(toolbar.isClearAllButtonVisible).isFalse()
}

private fun createSut(): WooPosCartViewModel {
return WooPosCartViewModel(
childrenToParentEventSender,
Expand Down
Loading

0 comments on commit 5b12dd8

Please sign in to comment.