diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt index 9ce20949a0d..377f431ec1f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosDialogWrapper.kt @@ -9,6 +9,8 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -27,7 +29,12 @@ fun WooPosDialogWrapper( onDismissRequest: () -> Unit, content: @Composable AnimatedVisibilityScope.() -> Unit ) { - Box(contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .statusBarsPadding() + .navigationBarsPadding() + ) { WooPosBackgroundOverlay( modifier = Modifier .semantics { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosGetRefundableItems.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosGetRefundableItems.kt new file mode 100644 index 00000000000..f01f0395eb9 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosGetRefundableItems.kt @@ -0,0 +1,98 @@ +package com.woocommerce.android.ui.woopos.orders + +import com.woocommerce.android.model.Order +import com.woocommerce.android.model.Refund +import com.woocommerce.android.util.CurrencyFormatter +import com.woocommerce.android.util.PriceUtils +import java.math.BigDecimal +import javax.inject.Inject + +class WooPosGetRefundableItems @Inject constructor( + private val currencyFormatter: CurrencyFormatter +) { + operator fun invoke( + order: Order, + refunds: List + ): List { + // Step 1: Filter to only product items (exclude fees, shipping, etc.) + val productItems = order.items.filter { it.productId != 0L } + + if (productItems.isEmpty()) { + return emptyList() + } + + // Step 2: Calculate max refundable quantities + val maxQuantities: Map = calculateMaxRefundQuantities(refunds, productItems) + + // Step 3: Expand items into individual rows + return productItems.flatMap { orderItem -> + val maxQuantity = maxQuantities[orderItem.itemId]?.toInt() ?: 0 + + if (maxQuantity <= 0) { + emptyList() + } else { + val unitPrice = calculateUnitPrice(orderItem) + val unitTax = calculateUnitTax(orderItem) + val formattedUnitPrice = PriceUtils.formatCurrency(unitPrice, order.currency, currencyFormatter) + val formattedUnitTax = PriceUtils.formatCurrency(unitTax, order.currency, currencyFormatter) + + (0 until maxQuantity).map { index -> + WooPosRefundableItem( + orderItemId = orderItem.itemId, + productId = orderItem.productId, + variationId = orderItem.variationId, + name = orderItem.name, + unitPrice = unitPrice, + unitTax = unitTax, + formattedUnitPrice = formattedUnitPrice, + formattedUnitTax = formattedUnitTax, + rowIndex = index + ) + } + } + } + } + + /** + * Calculate remaining refundable quantities for each order item. + */ + private fun calculateMaxRefundQuantities( + refunds: List, + productItems: List + ): Map { + val allRefundedItems = refunds.flatMap { it.items } + + return productItems.mapNotNull { orderItem -> + val refundedQuantity = allRefundedItems + .filter { refundedItem -> + refundedItem.productId == orderItem.productId && + refundedItem.variationId == orderItem.variationId + } + .sumOf { it.quantity } + + val remainingQuantity = orderItem.quantity - refundedQuantity + + if (remainingQuantity > 0) { + orderItem.itemId to remainingQuantity + } else { + null + } + }.toMap() + } + + private fun calculateUnitPrice(item: Order.Item): BigDecimal { + return if (item.quantity == 0f) { + item.total + } else { + item.total / item.quantity.toBigDecimal() + } + } + + private fun calculateUnitTax(item: Order.Item): BigDecimal { + return if (item.quantity == 0f) { + item.totalTax + } else { + item.totalTax / item.quantity.toBigDecimal() + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosIssueRefundDialog.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosIssueRefundDialog.kt index 86706bc49ed..ab6748eeb85 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosIssueRefundDialog.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosIssueRefundDialog.kt @@ -1,88 +1,428 @@ package com.woocommerce.android.ui.woopos.orders +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.woocommerce.android.R import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosButton import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosDialogWrapper +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosItemImage import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosText +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosCornerRadius import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosSpacing import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTypography +import java.math.BigDecimal + +private val COLUMN_WIDTH = 104.dp @Composable fun WooPosIssueRefundDialog( - isVisible: Boolean, orderId: Long, - onDismissRequest: () -> Unit + onDismissRequest: () -> Unit, + onContinue: () -> Unit ) { + val viewModel: WooPosRefundViewModel = hiltViewModel { factory -> + factory.create(orderId) + } + val state by viewModel.state.collectAsStateWithLifecycle() + WooPosDialogWrapper( - isVisible = isVisible, + isVisible = true, dialogBackgroundContentDescription = stringResource( R.string.woopos_orders_issue_refund_content_description ), onDismissRequest = onDismissRequest ) { - Column( + when (val currentState = state) { + is WooPosRefundState.Loading -> { + LoadingContent() + } + is WooPosRefundState.Content -> { + RefundDialogContent( + state = currentState, + onDismissRequest = onDismissRequest, + onContinue = onContinue + ) + } + is WooPosRefundState.Error -> { + ErrorContent( + message = currentState.message, + onDismissRequest = onDismissRequest + ) + } + is WooPosRefundState.NoRefundableItems -> { + NoItemsContent(onDismissRequest = onDismissRequest) + } + } + } +} + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(WooPosSpacing.XLarge.value), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorContent( + message: String, + onDismissRequest: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(WooPosSpacing.XLarge.value), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + WooPosText( + text = message, + style = WooPosTypography.BodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(WooPosSpacing.Large.value)) + WooPosButton( + text = stringResource(R.string.close), + onClick = onDismissRequest + ) + } +} + +@Composable +private fun NoItemsContent(onDismissRequest: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(WooPosSpacing.XLarge.value), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + WooPosText( + text = "No items available for refund", + style = WooPosTypography.BodyLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(WooPosSpacing.Large.value)) + WooPosButton( + text = stringResource(R.string.close), + onClick = onDismissRequest + ) + } +} + +@Composable +private fun RefundDialogContent( + state: WooPosRefundState.Content, + onDismissRequest: () -> Unit, + onContinue: () -> Unit +) { + Column(modifier = Modifier.fillMaxSize()) { + RefundDialogHeader(onDismissRequest = onDismissRequest) + + ItemsHeaderRow(itemsLabel = state.itemsLabel) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = WooPosSpacing.XLarge.value), + color = WooPosTheme.colors.outlineVariant, + thickness = 0.25.dp + ) + + LazyColumn( modifier = Modifier + .weight(1f) .fillMaxWidth() - .padding(WooPosSpacing.Large.value), - horizontalAlignment = Alignment.CenterHorizontally + .padding(horizontal = WooPosSpacing.XLarge.value) + .padding(vertical = WooPosSpacing.Medium.value), + verticalArrangement = Arrangement.spacedBy(WooPosSpacing.Medium.value) ) { - IconButton( - onClick = onDismissRequest, - modifier = Modifier.align(Alignment.End) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), - tint = MaterialTheme.colorScheme.onSurface - ) + itemsIndexed(state.refundableItems) { index, item -> + RefundableItemRow(item = item) + if (index < state.refundableItems.lastIndex) { + HorizontalDivider( + color = WooPosTheme.colors.outlineVariant, + thickness = 0.25.dp + ) + } } + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = WooPosSpacing.XLarge.value), + color = WooPosTheme.colors.outlineVariant, + thickness = 0.25.dp + ) + + WooPosButton( + text = stringResource(R.string.continue_button), + onClick = onContinue, + modifier = Modifier + .fillMaxWidth() + .padding(WooPosSpacing.XLarge.value) + ) + } +} + +@Composable +private fun RefundDialogHeader(onDismissRequest: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(WooPosSpacing.XLarge.value), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + WooPosText( + text = stringResource(R.string.woopos_orders_select_items_to_refund), + style = WooPosTypography.Heading, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + IconButton( + modifier = Modifier.size(48.dp), + onClick = onDismissRequest, + ) { + Icon( + modifier = Modifier.size(32.dp), + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } +} - Spacer(Modifier.height(WooPosSpacing.Medium.value)) +@Composable +private fun ItemsHeaderRow(itemsLabel: String) { + val selectAllContentDescription = stringResource(R.string.order_refunds_items_select_all) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = WooPosSpacing.XLarge.value) + .padding(bottom = WooPosSpacing.Medium.value), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(WooPosSpacing.Large.value) + ) { + Checkbox( + checked = true, + onCheckedChange = null, + modifier = Modifier + .size(32.dp) + .semantics { + contentDescription = selectAllContentDescription + }, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + checkmarkColor = MaterialTheme.colorScheme.onPrimary, + disabledCheckedColor = MaterialTheme.colorScheme.primary + ) + ) WooPosText( - text = stringResource(R.string.orderdetail_issue_refund_button), - style = WooPosTypography.Heading, + text = itemsLabel, + style = WooPosTypography.Caption, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = WooPosTheme.colors.onSurfaceVariantHighest ) + } + WooPosText( + modifier = Modifier.width(COLUMN_WIDTH), + text = stringResource(R.string.woopos_orders_amount), + style = WooPosTypography.Caption, + fontWeight = FontWeight.Bold, + color = WooPosTheme.colors.onSurfaceVariantHighest, + textAlign = TextAlign.End, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(WooPosSpacing.Medium.value)) + WooPosText( + modifier = Modifier.width(COLUMN_WIDTH), + text = stringResource(R.string.woopos_orders_tax), + style = WooPosTypography.Caption, + fontWeight = FontWeight.Bold, + color = WooPosTheme.colors.onSurfaceVariantHighest, + textAlign = TextAlign.End, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun RefundableItemRow(item: WooPosRefundableItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = WooPosSpacing.XSmall.value), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = true, + onCheckedChange = null, + modifier = Modifier + .size(32.dp) + .semantics { + contentDescription = item.name + }, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + checkmarkColor = MaterialTheme.colorScheme.onPrimary, + disabledCheckedColor = MaterialTheme.colorScheme.primary + ) + ) + Spacer(modifier = Modifier.size(WooPosSpacing.Large.value)) - Spacer(Modifier.height(WooPosSpacing.Large.value)) + WooPosItemImage( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(WooPosCornerRadius.Small.value)), + imageUrl = null, + placeholderIcon = Icons.Outlined.Inventory2, + placeholderIconSize = 24.dp + ) + Spacer(modifier = Modifier.size(WooPosSpacing.Medium.value)) + Column( + modifier = Modifier.weight(1f), + ) { WooPosText( - text = "Refund flow coming soon for order #$orderId", + text = item.name, + style = WooPosTypography.BodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + WooPosText( + text = "1", style = WooPosTypography.BodyMedium, color = WooPosTheme.colors.onSurfaceVariantHighest ) - - Spacer(Modifier.height(WooPosSpacing.Large.value)) } + + WooPosText( + text = item.formattedUnitPrice, + style = WooPosTypography.BodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + modifier = Modifier.width(COLUMN_WIDTH), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.size(WooPosSpacing.Medium.value)) + WooPosText( + text = item.formattedUnitTax, + style = WooPosTypography.BodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + modifier = Modifier.width(COLUMN_WIDTH), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } @WooPosPreview @Composable fun WooPosIssueRefundDialogPreview() { + val sampleItems = listOf( + WooPosRefundableItem( + orderItemId = 1, + productId = 100, + variationId = 0, + name = "Cup", + unitPrice = BigDecimal("18.00"), + unitTax = BigDecimal("1.80"), + formattedUnitPrice = "$18.00", + formattedUnitTax = "$1.80", + rowIndex = 0 + ), + WooPosRefundableItem( + orderItemId = 2, + productId = 200, + variationId = 0, + name = "Coffee Storage Container", + unitPrice = BigDecimal("30.00"), + unitTax = BigDecimal("3.00"), + formattedUnitPrice = "$30.00", + formattedUnitTax = "$3.00", + rowIndex = 0 + ), + WooPosRefundableItem( + orderItemId = 3, + productId = 300, + variationId = 0, + name = "Enamel Mug", + unitPrice = BigDecimal("8.50"), + unitTax = BigDecimal("0.85"), + formattedUnitPrice = "$8.50", + formattedUnitTax = "$0.85", + rowIndex = 0 + ) + ) + + val state = WooPosRefundState.Content( + orderId = 123, + orderNumber = "#123", + currency = "USD", + refundableItems = sampleItems, + itemsLabel = "ITEMS (3)", + subtotal = BigDecimal("57.00"), + taxes = BigDecimal("5.65"), + total = BigDecimal("62.65") + ) + WooPosTheme { - WooPosIssueRefundDialog( - isVisible = true, - orderId = 123L, - onDismissRequest = {} + RefundDialogContent( + state = state, + onDismissRequest = {}, + onContinue = {} ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt index a9da1756bd8..0aa09f0a5c3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt @@ -89,6 +89,15 @@ class WooPosOrdersDataSource @Inject constructor( } } + suspend fun getOrderById(orderId: Long): Result { + val cached = ordersCache.getAll().firstOrNull { it.id == orderId } + if (cached != null) { + return Result.success(cached) + } + + return refreshOrderById(orderId) + } + suspend fun refreshOrderById(orderId: Long): Result { val site = selectedSite.get() val payload = restClient.fetchSingleOrder(site, orderId) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt index 536412d4b45..fad6391eeed 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt @@ -146,8 +146,7 @@ private fun WooPosOrdersScreen( onSearchEvent = onSearchEvent, onSearchErrorRetry = onSearchErrorRetry, onEmailReceiptButtonClicked = onEmailReceiptButtonClicked, - onIssueRefundButtonClicked = onIssueRefundButtonClicked, - onIssueRefundDialogDismissed = onIssueRefundDialogDismissed + onIssueRefundButtonClicked = onIssueRefundButtonClicked ) is WooPosOrdersState.Empty -> OrdersEmpty( @@ -168,6 +167,19 @@ private fun WooPosOrdersScreen( modifier = Modifier.fillMaxWidth() ) } + + if (state is WooPosOrdersState.Content) { + when (val dialogState = state.dialogState) { + is WooPosOrdersState.Content.DialogState.IssueRefund -> { + WooPosIssueRefundDialog( + orderId = dialogState.orderId, + onDismissRequest = onIssueRefundDialogDismissed, + onContinue = onIssueRefundDialogDismissed + ) + } + WooPosOrdersState.Content.DialogState.Hidden -> Unit + } + } } } @@ -182,8 +194,7 @@ private fun OrdersContent( onSearchEvent: (WooPosSearchUIEvent) -> Unit, onSearchErrorRetry: () -> Unit, onEmailReceiptButtonClicked: (Long) -> Unit, - onIssueRefundButtonClicked: (Long) -> Unit, - onIssueRefundDialogDismissed: () -> Unit + onIssueRefundButtonClicked: (Long) -> Unit ) { Row(modifier = Modifier.fillMaxSize()) { OrdersListPane( @@ -217,17 +228,6 @@ private fun OrdersContent( ) } } - - when (val dialogState = state.dialogState) { - is WooPosOrdersState.Content.DialogState.IssueRefund -> { - WooPosIssueRefundDialog( - isVisible = true, - orderId = dialogState.orderId, - onDismissRequest = onIssueRefundDialogDismissed - ) - } - WooPosOrdersState.Content.DialogState.Hidden -> Unit - } } @OptIn(ExperimentalMaterialApi::class) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt index 3597292f61a..30a52b675dc 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt @@ -107,7 +107,9 @@ sealed class WooPosOrdersState { sealed class DialogState { data object Hidden : DialogState() - data class IssueRefund(val orderId: Long) : DialogState() + data class IssueRefund( + val orderId: Long + ) : DialogState() } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt index 1ed12e666af..7730956afac 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt @@ -195,7 +195,9 @@ class WooPosOrdersViewModel @Inject constructor( fun onIssueRefundButtonClicked(orderId: Long) { val currentState = _state.value as? WooPosOrdersState.Content ?: return _state.value = currentState.copy( - dialogState = WooPosOrdersState.Content.DialogState.IssueRefund(orderId) + dialogState = WooPosOrdersState.Content.DialogState.IssueRefund( + orderId = orderId + ) ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundState.kt new file mode 100644 index 00000000000..8230e9d2f02 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundState.kt @@ -0,0 +1,30 @@ +package com.woocommerce.android.ui.woopos.orders + +import androidx.compose.runtime.Immutable +import java.math.BigDecimal + +@Immutable +sealed class WooPosRefundState { + @Immutable + data object Loading : WooPosRefundState() + + @Immutable + data class Content( + val orderId: Long, + val orderNumber: String, + val currency: String, + val refundableItems: List, + val itemsLabel: String, + val subtotal: BigDecimal, + val taxes: BigDecimal, + val total: BigDecimal + ) : WooPosRefundState() + + @Immutable + data class Error( + val message: String + ) : WooPosRefundState() + + @Immutable + data object NoRefundableItems : WooPosRefundState() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundViewModel.kt new file mode 100644 index 00000000000..d0d045f2300 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundViewModel.kt @@ -0,0 +1,104 @@ +package com.woocommerce.android.ui.woopos.orders + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.woocommerce.android.R +import com.woocommerce.android.model.Order +import com.woocommerce.android.ui.woopos.common.data.WooPosRetrieveOrderRefunds +import com.woocommerce.android.viewmodel.ResourceProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = WooPosRefundViewModel.Factory::class) +class WooPosRefundViewModel @AssistedInject constructor( + @Assisted private val orderId: Long, + private val ordersDataSource: WooPosOrdersDataSource, + private val retrieveOrderRefunds: WooPosRetrieveOrderRefunds, + private val getRefundableItems: WooPosGetRefundableItems, + private val resourceProvider: ResourceProvider +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(orderId: Long): WooPosRefundViewModel + } + + private val _state = MutableStateFlow(WooPosRefundState.Loading) + val state: StateFlow = _state.asStateFlow() + + init { + loadRefundableItems() + } + + private fun loadRefundableItems() { + viewModelScope.launch { + _state.value = WooPosRefundState.Loading + + try { + val orderResult = ordersDataSource.getOrderById(orderId) + if (orderResult.isFailure) { + _state.value = WooPosRefundState.Error( + message = resourceProvider.getString(R.string.error_generic) + ) + return@launch + } + + val order = orderResult.getOrThrow() + + val refundsResult = retrieveOrderRefunds(order) + val refunds = if (refundsResult.isSuccess) { + refundsResult.getOrThrow() + } else { + emptyList() + } + + val refundableItems = getRefundableItems(order, refunds) + + if (refundableItems.isEmpty()) { + _state.value = WooPosRefundState.NoRefundableItems + return@launch + } + + _state.value = buildContentState( + order = order, + refundableItems = refundableItems + ) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + _state.value = WooPosRefundState.Error( + message = e.message ?: resourceProvider.getString(R.string.error_generic) + ) + } + } + } + + private fun buildContentState( + order: Order, + refundableItems: List + ): WooPosRefundState.Content { + val itemsLabel = resourceProvider.getString( + R.string.woopos_orders_items_count, + refundableItems.size + ) + + val subtotal = refundableItems.sumOf { it.lineTotal } + val taxes = refundableItems.sumOf { it.lineTax } + val total = subtotal + taxes + + return WooPosRefundState.Content( + orderId = order.id, + orderNumber = "#${order.number}", + currency = order.currency, + refundableItems = refundableItems, + itemsLabel = itemsLabel, + subtotal = subtotal, + taxes = taxes, + total = total + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundableItem.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundableItem.kt new file mode 100644 index 00000000000..e025f1f8c92 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosRefundableItem.kt @@ -0,0 +1,23 @@ +package com.woocommerce.android.ui.woopos.orders + +import androidx.compose.runtime.Immutable +import java.math.BigDecimal + +@Immutable +data class WooPosRefundableItem( + val orderItemId: Long, + val productId: Long, + val variationId: Long, + val name: String, + val unitPrice: BigDecimal, + val unitTax: BigDecimal, + val formattedUnitPrice: String, + val formattedUnitTax: String, + val rowIndex: Int, +) { + val lineTotal: BigDecimal + get() = unitPrice + + val lineTax: BigDecimal + get() = unitTax +} diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 7ea49526296..ef49a35e920 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3798,6 +3798,11 @@ Email receipt Issue a refund for this order + Select items to refund + ITEMS (%1$d SELECTED) + ITEMS (%1$d) + AMOUNT + TAX Select an order Choose an order from the list to view details. Products diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt index e183bc3dcfc..09a03c5bfb8 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt @@ -95,6 +95,8 @@ class WooPosOrdersViewModelTest { whenever(resourceProvider.getString(R.string.woopos_orders_status_completed)).thenReturn("Completed") whenever(resourceProvider.getString(R.string.woopos_orders_status_refunded)).thenReturn("Refunded") whenever(resourceProvider.getString(R.string.woopos_search_orders)).thenReturn("Search orders") + whenever(resourceProvider.getString(eq(R.string.woopos_orders_items_selected), any())) + .thenAnswer { "ITEMS (${it.arguments[1]} SELECTED)" } } @Test @@ -916,6 +918,31 @@ class WooPosOrdersViewModelTest { assertThat(state.dialogState).isInstanceOf(WooPosOrdersState.Content.DialogState.IssueRefund::class.java) val dialogState = state.dialogState as WooPosOrdersState.Content.DialogState.IssueRefund assertThat(dialogState.orderId).isEqualTo(123L) + verify(resourceProvider).getString(eq(R.string.woopos_orders_items_selected), any()) + } + + @Test + fun `given order with multiple line items, when onIssueRefundButtonClicked called, then itemsSelectedLabel is correctly formatted`() = runTest { + // GIVEN + val testOrder = order(123).copy( + items = OrderTestUtils.generateTestOrderItems(count = 3) + ) + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(testOrder))) } + ) + whenever(resourceProvider.getString(eq(R.string.woopos_orders_items_selected), eq(3))) + .thenReturn("ITEMS (3 SELECTED)") + viewModel = createViewModel() + advanceUntilIdle() + + // WHEN + viewModel.onIssueRefundButtonClicked(123L) + advanceUntilIdle() + + // THEN + val state = viewModel.state.value as WooPosOrdersState.Content + val dialogState = state.dialogState as WooPosOrdersState.Content.DialogState.IssueRefund + assertThat(dialogState.itemsSelectedLabel).isEqualTo("ITEMS (3 SELECTED)") } @Test