Skip to content
Open
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
-----
- [WEAR][*] Order and stats dates on the watch now follow the language's date order [https://github.com/woocommerce/woocommerce-android/pull/16429]
- [Internal] Fixed duplicated device registration API calls fired concurrently during login [https://github.com/woocommerce/woocommerce-android/pull/16354]
- [*] Woo POS: when the store rejects a refund because the order changed - another register refunded part of it - the refund screen now offers to reload the remaining items instead of a retry that cannot succeed
- [*] The Pay In Person toggle on the Payments screen no longer looks turned off when its status could not be loaded
- [Internal] Woo POS: every POS analytics event now carries device_type (phone/tablet) and entry_point, so POS usage can be split by form factor and attributed to where POS was opened from [https://github.com/woocommerce/woocommerce-android/pull/16414]
- [*] Woo POS: Remote Tap to Pay failures now explain what went wrong - phone not eligible for Tap to Pay, NFC turned off, or a payment service error - instead of a generic message or raw error text [https://github.com/woocommerce/woocommerce-android/pull/16384]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.woocommerce.android.ui.woopos.orders.details.refund

import com.woocommerce.android.model.Order
import com.woocommerce.android.util.CurrencyFormatter
import com.woocommerce.android.util.PriceUtils
import java.math.BigDecimal
import javax.inject.Inject

class WooPosBuildRefundContent @Inject constructor(
private val currencyFormatter: CurrencyFormatter,
) {
operator fun invoke(
order: Order,
refundableItems: List<WooPosRefundableItem>,
paymentMethod: String,
preservedSelection: Set<String>? = null,
): WooPosRefundState.Content {
val allItemIds = refundableItems.map { it.uniqueId }.toSet()
val selectedItemIds = preservedSelection
?.filterTo(mutableSetOf()) { it in allItemIds }
?.takeIf { it.isNotEmpty() }
?: allItemIds
val zero = PriceUtils.formatCurrency(BigDecimal.ZERO, order.currency, currencyFormatter)

return WooPosRefundState.Content(
orderId = order.id,
orderNumber = "#${order.number}",
currency = order.currency,
refundableItems = refundableItems,
selectedItemIds = selectedItemIds,
allItemsSelected = selectedItemIds.containsAll(allItemIds),
itemsCount = refundableItems.count { it.uniqueId in selectedItemIds },
subtotal = BigDecimal.ZERO,
taxes = BigDecimal.ZERO,
total = BigDecimal.ZERO,
formattedSubtotal = zero,
formattedTaxes = zero,
formattedTotal = zero,
paymentMethod = paymentMethod,
step = WooPosRefundState.Content.RefundStep.SelectItems
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -722,25 +722,7 @@ private fun RefundContentStepButtons(
onConnectReaderClicked: () -> Unit,
) {
when (state.step) {
WooPosRefundState.Content.RefundStep.SelectItems -> {
if (state.previewFailed) {
WooPosText(
text = state.previewErrorMessage ?: stringResource(R.string.woopos_refund_preview_error),
style = WooPosTypography.BodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth()
)
WooPosOutlinedButton(
text = stringResource(R.string.retry),
onClick = { onEvent(WooPosRefundUIEvent.RetryPreview) },
modifier = Modifier.fillMaxWidth()
)
}
ContinueToReviewButton(
state = continueToReviewButtonState(state),
onClick = { onEvent(WooPosRefundUIEvent.ContinueToReviewClicked) },
)
}
WooPosRefundState.Content.RefundStep.SelectItems -> SelectItemsStepButtons(state = state, onEvent = onEvent)
WooPosRefundState.Content.RefundStep.ReviewRefund -> {
// Back navigation is provided by the toolbar up button (see modalNavigationIcon).
WooPosButton(
Expand Down Expand Up @@ -816,39 +798,92 @@ private fun RefundContentStepButtons(
}
}

@Composable
private fun SelectItemsStepButtons(
state: WooPosRefundState.Content,
onEvent: (WooPosRefundUIEvent) -> Unit,
) {
val previewFailure = state.previewFailure
if (previewFailure != null) {
WooPosText(
text = previewFailure.message ?: stringResource(R.string.woopos_refund_preview_error),
style = WooPosTypography.BodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth()
)
when (previewFailure.recovery) {
WooPosRefundState.Recovery.Retry -> WooPosOutlinedButton(
text = stringResource(R.string.retry),
onClick = { onEvent(WooPosRefundUIEvent.RetryPreview) },
modifier = Modifier.fillMaxWidth()
)

WooPosRefundState.Recovery.RefreshItems -> WooPosOutlinedButton(
text = stringResource(R.string.woopos_refund_review_remaining_items_button),
onClick = { onEvent(WooPosRefundUIEvent.RefreshRefundableItems) },
modifier = Modifier.fillMaxWidth()
)

WooPosRefundState.Recovery.None -> Unit
}
}

ContinueToReviewButton(
state = continueToReviewButtonState(state),
onClick = { onEvent(WooPosRefundUIEvent.ContinueToReviewClicked) },
)
}

@Composable
private fun RefundErrorButtons(
state: WooPosRefundState.Error,
onDismiss: () -> Unit,
onCancelRefundFlow: () -> Unit,
onEvent: (WooPosRefundUIEvent) -> Unit,
) {
if (state.canRetry) {
WooPosButton(
text = stringResource(R.string.retry),
onClick = {
onEvent(
when (state.errorType) {
WooPosRefundState.Error.ErrorType.Loading ->
WooPosRefundUIEvent.RetryLoadRefundableItems
WooPosRefundState.Error.ErrorType.Processing ->
WooPosRefundUIEvent.RetryCreateRefund
}
)
},
modifier = Modifier.fillMaxWidth()
)
WooPosOutlinedButton(
text = stringResource(R.string.cancel),
onClick = onCancelRefundFlow,
modifier = Modifier.fillMaxWidth()
)
} else {
WooPosButton(
text = stringResource(R.string.woopos_refund_back_to_order_button),
onClick = { onDismiss() },
modifier = Modifier.fillMaxWidth()
)
when (state.recovery) {
WooPosRefundState.Recovery.Retry -> {
WooPosButton(
text = stringResource(R.string.retry),
onClick = {
onEvent(
when (state.errorType) {
WooPosRefundState.Error.ErrorType.Loading ->
WooPosRefundUIEvent.RetryLoadRefundableItems
WooPosRefundState.Error.ErrorType.Processing ->
WooPosRefundUIEvent.RetryCreateRefund
}
)
},
modifier = Modifier.fillMaxWidth()
)
WooPosOutlinedButton(
text = stringResource(R.string.cancel),
onClick = onCancelRefundFlow,
modifier = Modifier.fillMaxWidth()
)
}

WooPosRefundState.Recovery.RefreshItems -> {
WooPosButton(
text = stringResource(R.string.woopos_refund_review_remaining_items_button),
onClick = { onEvent(WooPosRefundUIEvent.RefreshRefundableItems) },
modifier = Modifier.fillMaxWidth()
)
WooPosOutlinedButton(
text = stringResource(R.string.woopos_refund_back_to_order_button),
onClick = { onDismiss() },
modifier = Modifier.fillMaxWidth()
)
}

WooPosRefundState.Recovery.None -> {
WooPosButton(
text = stringResource(R.string.woopos_refund_back_to_order_button),
onClick = { onDismiss() },
modifier = Modifier.fillMaxWidth()
)
}
}
}

Expand Down Expand Up @@ -892,7 +927,7 @@ private fun ContinueToReviewButton(

private fun continueToReviewButtonState(state: WooPosRefundState.Content): WooPosButtonState = when {
state.isPreviewLoading -> WooPosButtonState.LOADING
state.selectedItemIds.isEmpty() || state.previewFailed -> WooPosButtonState.DISABLED
state.selectedItemIds.isEmpty() || state.previewFailure != null -> WooPosButtonState.DISABLED
else -> WooPosButtonState.ENABLED
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,34 @@ import com.woocommerce.android.R
* Programming-error codes (invalid line item ids, malformed payloads, etc.) intentionally have
* no entry here: they indicate a client bug and keep the generic error message.
*/
enum class WooPosRefundApiError(@StringRes val messageRes: Int) {
QuantityExceedsRefundable(R.string.woopos_refund_error_quantity_exceeds_refundable),
LineItemAlreadyRefunded(R.string.woopos_refund_error_item_already_refunded),
OrderNotRefundable(R.string.woopos_refund_error_order_not_refundable),
AmountExceedsOrderRemaining(R.string.woopos_refund_error_amount_exceeds_order_remaining),
AmountExceedsItemRemaining(R.string.woopos_refund_error_amount_exceeds_item_remaining),
InvalidAmount(R.string.woopos_refund_error_invalid_amount);
enum class WooPosRefundApiError(
@StringRes val messageRes: Int,
val recovery: WooPosRefundState.Recovery,
) {
QuantityExceedsRefundable(
R.string.woopos_refund_error_quantity_exceeds_refundable,
WooPosRefundState.Recovery.RefreshItems,
),
LineItemAlreadyRefunded(
R.string.woopos_refund_error_item_already_refunded,
WooPosRefundState.Recovery.RefreshItems,
),
OrderNotRefundable(
R.string.woopos_refund_error_order_not_refundable,
WooPosRefundState.Recovery.None,
),
AmountExceedsOrderRemaining(
R.string.woopos_refund_error_amount_exceeds_order_remaining,
WooPosRefundState.Recovery.RefreshItems,
),
AmountExceedsItemRemaining(
R.string.woopos_refund_error_amount_exceeds_item_remaining,
WooPosRefundState.Recovery.RefreshItems,
),
InvalidAmount(
R.string.woopos_refund_error_invalid_amount,
WooPosRefundState.Recovery.None,
);

companion object {
fun fromCode(code: String?): WooPosRefundApiError? = when (code) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import java.math.BigDecimal
@Immutable
sealed class WooPosRefundState {

enum class Recovery {
Retry,
RefreshItems,
None,
}

@Immutable
data object Loading : WooPosRefundState()

Expand All @@ -29,10 +35,15 @@ sealed class WooPosRefundState {
val refundReason: String = "",
val step: RefundStep,
val isPreviewLoading: Boolean = false,
val previewFailed: Boolean = false,
val previewErrorMessage: String? = null,
val previewFailure: PreviewFailure? = null,
) : WooPosRefundState() {

@Immutable
data class PreviewFailure(
val message: String?,
val recovery: Recovery,
)

@Immutable
sealed class RefundStep {
fun isNonCancelable(): Boolean {
Expand Down Expand Up @@ -85,7 +96,7 @@ sealed class WooPosRefundState {
data class Error(
val message: String,
val errorType: ErrorType,
val canRetry: Boolean = true,
val recovery: Recovery = Recovery.Retry,
) : WooPosRefundState() {

@Immutable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,13 @@ class WooPosRefundSubmissionProcessor @Inject constructor(
"message=${error.message}, " +
"errorData=${error.errorData}"
)
// Unmapped codes get the generic message, like the preview path. The store's own message
// is technical and in the store's locale, so it stays in the log.
val mappedMessage = WooPosRefundApiError.fromCode(error.apiErrorCode)
?.let { resourceProvider.getString(it.messageRes) }
trySendState(
WooPosRefundSubmissionState.Failure(
message = mappedMessage
?: error.message
?: resourceProvider.getString(R.string.error_generic),
retryBackendNotificationOnly = retryBackendNotificationOnly,
apiErrorCode = error.apiErrorCode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ sealed class WooPosRefundUIEvent {
data object OnRefundConfirmed : WooPosRefundUIEvent()
data object RefundFlowDismissed : WooPosRefundUIEvent()
data object RetryLoadRefundableItems : WooPosRefundUIEvent()
data object RefreshRefundableItems : WooPosRefundUIEvent()
data object RetryCreateRefund : WooPosRefundUIEvent()
data object ConnectReaderClicked : WooPosRefundUIEvent()
data object CancelRefund : WooPosRefundUIEvent()
Expand Down
Loading
Loading