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 a737709b216..5db25e54162 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 @@ -795,30 +795,32 @@ private fun sampleOrderDetails( dateTime = "Aug 28, 2025 at 10:31 AM", customerEmail = "johndoe@mail.com", status = PosOrderStatus(text = "Completed", colorKey = OrderStatusColorKey.COMPLETED), - lineItems = listOf( - WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( - id = 101, - name = "Cup", - attributesDescription = null, - qtyAndUnitPrice = "1 x $8.50", - lineTotal = "$15.00", - imageUrl = null - ), - WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( - id = 102, - name = "Coffee Container", - attributesDescription = "Blue, Large", - qtyAndUnitPrice = "1 x $10.00", - lineTotal = "$8.00", - imageUrl = null - ), - WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( - id = 103, - name = "Paper Filter", - attributesDescription = null, - qtyAndUnitPrice = "1 x $4.50", - lineTotal = "$8.00", - imageUrl = null + lineItems = WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemsState.Loaded( + listOf( + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( + id = 101, + name = "Cup", + attributesDescription = null, + qtyAndUnitPrice = "1 x $8.50", + lineTotal = "$15.00", + imageUrl = null + ), + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( + id = 102, + name = "Coffee Container", + attributesDescription = "Blue, Large", + qtyAndUnitPrice = "1 x $10.00", + lineTotal = "$8.00", + imageUrl = null + ), + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( + id = 103, + name = "Paper Filter", + attributesDescription = null, + qtyAndUnitPrice = "1 x $4.50", + lineTotal = "$8.00", + imageUrl = null + ) ) ), breakdown = WooPosOrdersState.OrderDetailsViewState.Computed.Details.TotalsBreakdown( 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 30d7d4e8821..eb5b31cef9d 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 @@ -56,13 +56,23 @@ sealed class WooPosOrdersState { val customerEmail: String?, val status: PosOrderStatus, - val lineItems: List, + val lineItems: LineItemsState = LineItemsState.Loading, + val refundedLineItems: LineItemsState = LineItemsState.Loading, val breakdown: TotalsBreakdown, val total: String, val totalPaid: String, val paymentMethodTitle: String?, val actionsState: OrderActionsState ) { + @Immutable + sealed interface LineItemsState { + @Immutable + data object Loading : LineItemsState + + @Immutable + data class Loaded(val items: List) : LineItemsState + } + @Immutable sealed interface BookingInfo { @Immutable 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 60a3b91caec..dd66975d331 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 @@ -14,6 +14,7 @@ import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState import com.woocommerce.android.ui.woopos.home.items.WooPosPullToRefreshState import com.woocommerce.android.ui.woopos.orders.WooPosOrdersState.OrderDetailsViewState.Computed.Details.BookingInfo +import com.woocommerce.android.ui.woopos.orders.WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemsState import com.woocommerce.android.ui.woopos.orders.details.WooPosBookingInfoMapper import com.woocommerce.android.ui.woopos.orders.details.WooPosOrderDetailsMapper import com.woocommerce.android.ui.woopos.orders.details.WooPosOrderItemMapper @@ -253,6 +254,8 @@ class WooPosOrdersViewModel @Inject constructor( val actions = orderActionsProvider.getAvailableActions(order) val refundInfo = refundInfoBuilder.buildRefundInfo(order, refundsResult) val updatedBreakdown = refundInfoBuilder.buildTotalsBreakdown(order, refundInfo) + val refundedLineItems = orderDetailsMapper.buildRefundedLineItems(order, refundsResult) + val nonRefundedLineItems = orderDetailsMapper.buildNonRefundedLineItems(order, refundsResult) val updatedState = _state.value as? WooPosOrdersState.Content ?: return@launch if (updatedState.selectedDetails?.id == orderId && @@ -261,9 +264,17 @@ class WooPosOrdersViewModel @Inject constructor( _state.value = updatedState.withUpdatedDetails(orderId) { details -> details.copy( actionsState = WooPosOrdersState.OrderActionsState.Loaded(actions), - breakdown = updatedBreakdown + breakdown = updatedBreakdown, + lineItems = LineItemsState.Loaded(nonRefundedLineItems), + refundedLineItems = LineItemsState.Loaded(refundedLineItems) ) } + + val stateAfterUpdate = _state.value as? WooPosOrdersState.Content + val updatedDetails = stateAfterUpdate?.selectedDetails + if (updatedDetails != null) { + sideLoadBookings(orderId, updatedDetails) + } } } } @@ -272,7 +283,8 @@ class WooPosOrdersViewModel @Inject constructor( orderId: Long, details: WooPosOrdersState.OrderDetailsViewState.Computed.Details ) { - val loadingItems = details.lineItems.filter { it.bookingInfo is BookingInfo.Loading } + val loadedItems = (details.lineItems as? LineItemsState.Loaded)?.items ?: return + val loadingItems = loadedItems.filter { it.bookingInfo is BookingInfo.Loading } if (loadingItems.isEmpty()) return viewModelScope.launch { @@ -289,10 +301,14 @@ class WooPosOrdersViewModel @Inject constructor( if (currentState.selectedDetails?.id != orderId) return@launch _state.value = currentState.withUpdatedDetails(orderId) { details -> + val currentItems = (details.lineItems as? LineItemsState.Loaded)?.items + ?: return@withUpdatedDetails details details.copy( - lineItems = details.lineItems.map { lineItem -> - results[lineItem.id]?.let { lineItem.copy(bookingInfo = it) } ?: lineItem - } + lineItems = LineItemsState.Loaded( + currentItems.map { lineItem -> + results[lineItem.id]?.let { lineItem.copy(bookingInfo = it) } ?: lineItem + } + ) ) } } @@ -640,20 +656,11 @@ class WooPosOrdersViewModel @Inject constructor( val orderDetails = loadedItems.items.values.firstOrNull { it.orderId == orderId } ?: error("Order $orderId not found in state") - return orderDetailsMapper.mapOrderDetails( - when (orderDetails) { - is WooPosOrdersState.OrderDetailsViewState.Lazy -> orderDetails.order - is WooPosOrdersState.OrderDetailsViewState.Computed -> { - loadedItems.items.keys.firstOrNull { it.id == orderId }?.let { - ordersDataSource.getOrderById(it.id).getOrNull() - } ?: error("Order $orderId not found") - } - }, - when (orderDetails) { - is WooPosOrdersState.OrderDetailsViewState.Lazy -> orderDetails.refundResult - is WooPosOrdersState.OrderDetailsViewState.Computed -> RefundsFetchResult.Error - } - ) + return when (orderDetails) { + is WooPosOrdersState.OrderDetailsViewState.Computed -> orderDetails.details + is WooPosOrdersState.OrderDetailsViewState.Lazy -> + orderDetailsMapper.mapOrderDetails(orderDetails.order, orderDetails.refundResult) + } } private suspend fun getOrComputeDetailsWithoutActions( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGetNonRefundedItems.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGetNonRefundedItems.kt new file mode 100644 index 00000000000..cf778d35986 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGetNonRefundedItems.kt @@ -0,0 +1,43 @@ +package com.woocommerce.android.ui.woopos.orders.details + +import com.woocommerce.android.model.Order +import com.woocommerce.android.model.Refund +import java.math.RoundingMode +import javax.inject.Inject +import kotlin.math.abs + +class WooPosGetNonRefundedItems @Inject constructor() { + operator fun invoke( + order: Order, + refunds: List, + ): List { + if (refunds.isEmpty()) return order.items + + val refundedByItemId = refunds + .flatMap { it.items } + .groupingBy { it.orderItemId } + .fold(0) { acc, item -> acc + abs(item.quantity) } + + return order.items.mapNotNull { item -> + val refundedQty = (refundedByItemId[item.itemId] ?: 0).toFloat() + + if (item.quantity == 0f) { + return@mapNotNull if (refundedQty == 0f) item else null + } + + val remaining = item.quantity - refundedQty + when { + remaining <= 0f -> null + remaining == item.quantity -> item + else -> { + val newTotal = (item.total * remaining.toBigDecimal()).divide( + item.quantity.toBigDecimal(), + item.total.scale(), + RoundingMode.HALF_UP + ) + item.copy(quantity = remaining, total = newTotal) + } + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGroupRefundedItems.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGroupRefundedItems.kt new file mode 100644 index 00000000000..a99b9060b90 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGroupRefundedItems.kt @@ -0,0 +1,30 @@ +package com.woocommerce.android.ui.woopos.orders.details + +import com.woocommerce.android.model.Refund +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +class WooPosGroupRefundedItems @Inject constructor() { + operator fun invoke(refunds: List): List { + val allRefundItems = refunds.flatMap { it.items } + if (allRefundItems.isEmpty()) return emptyList() + + return allRefundItems + .groupBy { it.orderItemId } + .map { (orderItemId, items) -> + val quantity = items.sumOf { it.quantity } + val total = items.fold(BigDecimal.ZERO) { acc, item -> acc + item.total } + items.first().copy( + orderItemId = orderItemId, + quantity = quantity, + total = total, + price = if (quantity != 0) { + total.divide(BigDecimal.valueOf(quantity.toLong()), total.scale(), RoundingMode.HALF_UP) + } else { + total + }, + ) + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetails.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetails.kt index 86b1091dd99..a16079193e2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetails.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetails.kt @@ -91,7 +91,12 @@ fun WooPosOrderDetails( Spacer(Modifier.height(WooPosSpacing.Large.value)) - OrdersProducts(lineItems = details.lineItems) + val lineItemsState = details.lineItems + if (lineItemsState is WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemsState.Loaded && + lineItemsState.items.isNotEmpty() + ) { + OrdersProducts(lineItems = lineItemsState.items) + } Spacer(Modifier.height(WooPosSpacing.Medium.value)) @@ -470,41 +475,43 @@ fun WooPosOrderDetailsPreview() { dateTime = "Aug 28, 2025 at 10:31 AM", customerEmail = "johndoe@mail.com", status = PosOrderStatus(text = "Completed", colorKey = OrderStatusColorKey.COMPLETED), - lineItems = listOf( - WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( - id = 101, - name = "Cup", - attributesDescription = null, - qtyAndUnitPrice = "2 x $4.00", - lineTotal = "$8.00", - imageUrl = null - ), - WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( - id = 102, - name = "T-Shirt", - attributesDescription = "Blue, Large", - qtyAndUnitPrice = "1 x $10.00", - lineTotal = "$10.00", - imageUrl = null - ), - WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( - id = 103, - name = "A vey tasty coffee that incidentally has a very long name " + - "and should go over a few lines without overlapping anything", - attributesDescription = "Medium roast, Decaf", - qtyAndUnitPrice = "1 x $5.00", - lineTotal = "$5.00", - imageUrl = null - ), - WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( - id = 104, - name = "Women's Haircut", - attributesDescription = null, - qtyAndUnitPrice = "1 x $55.00", - lineTotal = "$55.00", - imageUrl = null, - bookingInfo = WooPosOrdersState.OrderDetailsViewState.Computed.Details.BookingInfo.Loaded( - "Booking #33 \u00B7 Jul 5, 2025, 10:00 AM - 10:30 AM" + lineItems = WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemsState.Loaded( + listOf( + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( + id = 101, + name = "Cup", + attributesDescription = null, + qtyAndUnitPrice = "2 x $4.00", + lineTotal = "$8.00", + imageUrl = null + ), + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( + id = 102, + name = "T-Shirt", + attributesDescription = "Blue, Large", + qtyAndUnitPrice = "1 x $10.00", + lineTotal = "$10.00", + imageUrl = null + ), + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( + id = 103, + name = "A vey tasty coffee that incidentally has a very long name " + + "and should go over a few lines without overlapping anything", + attributesDescription = "Medium roast, Decaf", + qtyAndUnitPrice = "1 x $5.00", + lineTotal = "$5.00", + imageUrl = null + ), + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow( + id = 104, + name = "Women's Haircut", + attributesDescription = null, + qtyAndUnitPrice = "1 x $55.00", + lineTotal = "$55.00", + imageUrl = null, + bookingInfo = WooPosOrdersState.OrderDetailsViewState.Computed.Details.BookingInfo.Loaded( + "Booking #33 \u00B7 Jul 5, 2025, 10:00 AM - 10:30 AM" + ) ) ) ), diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapper.kt index 3696c8fafde..73f261bff66 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapper.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.ui.woopos.orders.RefundsFetchResult import com.woocommerce.android.ui.woopos.orders.WooPosOrderActionsProvider import com.woocommerce.android.ui.woopos.orders.WooPosOrdersState import com.woocommerce.android.ui.woopos.orders.WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemRow +import com.woocommerce.android.ui.woopos.orders.WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemsState import com.woocommerce.android.ui.woopos.orders.details.refund.RefundInfo import com.woocommerce.android.ui.woopos.orders.details.refund.WooPosRefundInfoBuilder import com.woocommerce.android.ui.woopos.util.ext.formatToMMMddYYYYAtHHmm @@ -16,6 +17,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import java.math.BigDecimal +import java.math.RoundingMode import javax.inject.Inject class WooPosOrderDetailsMapper @Inject constructor( @@ -26,16 +28,24 @@ class WooPosOrderDetailsMapper @Inject constructor( private val refundInfoBuilder: WooPosRefundInfoBuilder, private val orderActionsProvider: WooPosOrderActionsProvider, private val bookingInfoMapper: WooPosBookingInfoMapper, + private val getNonRefundedItems: WooPosGetNonRefundedItems, + private val groupRefundedItems: WooPosGroupRefundedItems, ) { suspend fun mapOrderDetails( order: Order, historicalRefundsResult: RefundsFetchResult ): WooPosOrdersState.OrderDetailsViewState.Computed.Details = coroutineScope { val status = orderStatusMapper.mapOrderStatus(order.status) - val lineItems = buildLineItems(order) + val refunds = when (historicalRefundsResult) { + is RefundsFetchResult.Success -> historicalRefundsResult.refunds + is RefundsFetchResult.Error -> emptyList() + } + val nonRefundedItems = getNonRefundedItems(order, refunds) + val lineItems = buildLineItems(order, nonRefundedItems) val refundInfo = refundInfoBuilder.buildRefundInfo(order, historicalRefundsResult) val breakdown = refundInfoBuilder.buildTotalsBreakdown(order, refundInfo) val actions = orderActionsProvider.getAvailableActions(order) + val refundedLineItems = buildRefundedLineItems(order, historicalRefundsResult) WooPosOrdersState.OrderDetailsViewState.Computed.Details( id = order.id, @@ -45,7 +55,8 @@ class WooPosOrderDetailsMapper @Inject constructor( ), customerEmail = order.customer?.email ?: order.billingAddress.email, status = status, - lineItems = lineItems, + lineItems = LineItemsState.Loaded(lineItems), + refundedLineItems = LineItemsState.Loaded(refundedLineItems), breakdown = breakdown, total = formatPrice(order.total, order.currency), totalPaid = if (order.isOrderPaid) { @@ -62,7 +73,11 @@ class WooPosOrderDetailsMapper @Inject constructor( order: Order ): WooPosOrdersState.OrderDetailsViewState.Computed.Details = coroutineScope { val status = orderStatusMapper.mapOrderStatus(order.status) - val lineItems = buildLineItems(order) + val mayHaveRefunds = order.refundTotal > BigDecimal.ZERO || + order.status == Order.Status.Refunded + val lineItems = if (mayHaveRefunds) LineItemsState.Loading else LineItemsState.Loaded(buildLineItems(order)) + val refundedLineItems = + if (mayHaveRefunds) LineItemsState.Loading else LineItemsState.Loaded(emptyList()) val refundInfo = RefundInfo(emptyList(), BigDecimal.ZERO) val breakdown = refundInfoBuilder.buildTotalsBreakdown(order, refundInfo) @@ -75,6 +90,7 @@ class WooPosOrderDetailsMapper @Inject constructor( customerEmail = order.customer?.email ?: order.billingAddress.email, status = status, lineItems = lineItems, + refundedLineItems = refundedLineItems, breakdown = breakdown, total = formatPrice(order.total), totalPaid = if (order.isOrderPaid) { @@ -87,10 +103,61 @@ class WooPosOrderDetailsMapper @Inject constructor( ) } + suspend fun buildRefundedLineItems( + order: Order, + refundResult: RefundsFetchResult + ): List = coroutineScope { + val refunds = when (refundResult) { + is RefundsFetchResult.Success -> refundResult.refunds + is RefundsFetchResult.Error -> return@coroutineScope emptyList() + } + + val groupedItems = groupRefundedItems(refunds) + + groupedItems.map { refundItem -> + async { + val orderItem = order.items.find { it.itemId == refundItem.orderItemId } + val name = orderItem?.name ?: refundItem.name + val attributesDescription = orderItem?.attributesDescription?.takeIf { it.isNotEmpty() } + val unitPrice = if (refundItem.quantity != 0) { + refundItem.total.divide( + BigDecimal.valueOf(refundItem.quantity.toLong()), + refundItem.total.scale(), + RoundingMode.HALF_UP + ) + } else { + refundItem.total + } + val product = getProductById(refundItem.productId) + LineItemRow( + id = refundItem.orderItemId, + name = name, + attributesDescription = attributesDescription, + qtyAndUnitPrice = "${refundItem.quantity} x ${formatPrice(unitPrice, order.currency)}", + lineTotal = formatPrice(refundItem.total.negate(), order.currency), + imageUrl = product?.firstImageUrl, + ) + } + }.awaitAll() + } + + suspend fun buildNonRefundedLineItems( + order: Order, + refundResult: RefundsFetchResult + ): List { + val refunds = when (refundResult) { + is RefundsFetchResult.Success -> refundResult.refunds + is RefundsFetchResult.Error -> emptyList() + } + val items = getNonRefundedItems(order, refunds) + return buildLineItems(order, items) + } + private suspend fun buildLineItems( - order: Order + order: Order, + items: List = order.items ): List = coroutineScope { - order.items.map { item -> + items.map { item -> async { val unitPrice = if (item.quantity == 0f) { @@ -104,7 +171,7 @@ class WooPosOrderDetailsMapper @Inject constructor( id = item.itemId, name = item.name, attributesDescription = item.attributesDescription.takeIf { it.isNotEmpty() }, - qtyAndUnitPrice = "${item.quantity.toInt()} x ${formatPrice(unitPrice)}", + qtyAndUnitPrice = "${item.quantity.toInt()} x ${formatPrice(unitPrice, order.currency)}", lineTotal = formatPrice(item.total, order.currency), imageUrl = product?.firstImageUrl, bookingInfo = bookingInfo, 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 b53599496a6..f35c5c914ab 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 @@ -14,6 +14,8 @@ import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState import com.woocommerce.android.ui.woopos.home.items.WooPosPullToRefreshState import com.woocommerce.android.ui.woopos.orders.details.WooPosBookingInfoMapper +import com.woocommerce.android.ui.woopos.orders.details.WooPosGetNonRefundedItems +import com.woocommerce.android.ui.woopos.orders.details.WooPosGroupRefundedItems import com.woocommerce.android.ui.woopos.orders.details.WooPosOrderDetailsMapper import com.woocommerce.android.ui.woopos.orders.details.WooPosOrderItemMapper import com.woocommerce.android.ui.woopos.orders.details.WooPosOrderStatusMapper @@ -147,6 +149,8 @@ class WooPosOrdersViewModelTest { refundInfoBuilder, orderActionsProvider, bookingInfoMapper, + WooPosGetNonRefundedItems(), + WooPosGroupRefundedItems(), ) orderItemMapper = WooPosOrderItemMapper(resourceProvider, formatPrice, orderStatusMapper) } @@ -645,7 +649,9 @@ class WooPosOrdersViewModelTest { val selectedItemId = loadedItems.items.keys.single { it.isSelected }.id assertThat(selectedItemId).isEqualTo(2L) assertThat(content.selectedDetails?.id).isEqualTo(2L) - assertThat(content.selectedDetails?.lineItems).isNotEmpty + assertThat(content.selectedDetails?.lineItems).isInstanceOf( + WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemsState.Loaded::class.java + ) assertThat(content.selectedDetails?.total).isEqualTo("$106.00") } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGetNonRefundedItemsTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGetNonRefundedItemsTest.kt new file mode 100644 index 00000000000..7e942d4ad90 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGetNonRefundedItemsTest.kt @@ -0,0 +1,223 @@ +package com.woocommerce.android.ui.woopos.orders.details + +import com.woocommerce.android.model.Order +import com.woocommerce.android.model.Refund +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.math.BigDecimal +import java.util.Date + +class WooPosGetNonRefundedItemsTest { + + private val sut = WooPosGetNonRefundedItems() + + private fun createOrderItem( + itemId: Long, + productId: Long = 10L, + name: String = "Product $itemId", + price: BigDecimal = BigDecimal("10.00"), + quantity: Float = 1f, + ) = Order.Item( + itemId = itemId, + productId = productId, + name = name, + price = price, + sku = "", + quantity = quantity, + subtotal = price * quantity.toBigDecimal(), + subtotalTax = BigDecimal.ZERO, + totalTax = BigDecimal.ZERO, + total = price * quantity.toBigDecimal(), + variationId = 0, + attributesList = emptyList(), + ) + + private fun createRefundItem( + orderItemId: Long, + quantity: Int = 1, + total: BigDecimal = BigDecimal("10.00"), + ) = Refund.Item( + productId = 10L, + quantity = quantity, + orderItemId = orderItemId, + name = "Refund Product", + total = total, + price = if (quantity > 0) total / quantity.toBigDecimal() else total, + ) + + private fun createRefund( + id: Long = 1L, + items: List, + ) = Refund( + id = id, + dateCreated = Date(), + amount = items.fold(BigDecimal.ZERO) { acc, item -> acc + item.total }, + reason = null, + automaticGatewayRefund = false, + items = items, + shippingLines = emptyList(), + feeLines = emptyList(), + ) + + private fun createOrder(items: List): Order { + return com.woocommerce.android.ui.orders.OrderTestUtils.generateTestOrder().copy(items = items) + } + + @Test + fun `given no refunds, when invoked, then all items returned`() { + // GIVEN + val items = listOf( + createOrderItem(itemId = 1L, name = "Cup", price = BigDecimal("4.00"), quantity = 2f), + ) + val order = createOrder(items) + + // WHEN + val result = sut(order, emptyList()) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().name).isEqualTo("Cup") + assertThat(result.first().quantity).isEqualTo(2f) + } + + @Test + fun `given fully refunded item, when invoked, then item excluded`() { + // GIVEN + val items = listOf( + createOrderItem(itemId = 1L, name = "Cup", price = BigDecimal("4.00"), quantity = 1f), + createOrderItem(itemId = 2L, name = "Plate", price = BigDecimal("6.00"), quantity = 1f), + ) + val order = createOrder(items) + val refunds = listOf( + createRefund( + items = listOf(createRefundItem(orderItemId = 1L, quantity = 1, total = BigDecimal("4.00"))) + ) + ) + + // WHEN + val result = sut(order, refunds) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().name).isEqualTo("Plate") + } + + @Test + fun `given partial refund, when invoked, then item has prorated total`() { + // GIVEN + val items = listOf( + createOrderItem(itemId = 1L, name = "Cup", price = BigDecimal("4.00"), quantity = 3f), + ) + val order = createOrder(items) + val refunds = listOf( + createRefund( + items = listOf(createRefundItem(orderItemId = 1L, quantity = 1, total = BigDecimal("4.00"))) + ) + ) + + // WHEN + val result = sut(order, refunds) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().quantity).isEqualTo(2f) + assertThat(result.first().total).isEqualByComparingTo(BigDecimal("8.00")) + } + + @Test + fun `given same product in different line items, when one is refunded, then only that line item affected`() { + // GIVEN + val items = listOf( + createOrderItem( + itemId = 1L, + productId = 10L, + name = "Cup (Red)", + price = BigDecimal("4.00"), + quantity = 1f + ), + createOrderItem( + itemId = 2L, + productId = 10L, + name = "Cup (Blue)", + price = BigDecimal("4.00"), + quantity = 1f + ), + ) + val order = createOrder(items) + val refunds = listOf( + createRefund( + items = listOf(createRefundItem(orderItemId = 1L, quantity = 1, total = BigDecimal("4.00"))) + ) + ) + + // WHEN + val result = sut(order, refunds) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().name).isEqualTo("Cup (Blue)") + } + + @Test + fun `given zero-quantity item with no refunds, when invoked, then item is preserved`() { + // GIVEN + val items = listOf( + createOrderItem(itemId = 1L, name = "Free Gift", price = BigDecimal.ZERO, quantity = 0f), + ) + val order = createOrder(items) + + // WHEN + val result = sut(order, emptyList()) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().name).isEqualTo("Free Gift") + assertThat(result.first().quantity).isEqualTo(0f) + } + + @Test + fun `given zero-quantity item with refunds, when invoked, then item is excluded`() { + // GIVEN + val items = listOf( + createOrderItem(itemId = 1L, name = "Free Gift", price = BigDecimal.ZERO, quantity = 0f), + ) + val order = createOrder(items) + val refunds = listOf( + createRefund( + items = listOf(createRefundItem(orderItemId = 1L, quantity = 1, total = BigDecimal.ZERO)) + ) + ) + + // WHEN + val result = sut(order, refunds) + + // THEN + assertThat(result).isEmpty() + } + + @Test + fun `given multiple refunds for same item, when invoked, then quantities are folded correctly`() { + // GIVEN + val items = listOf( + createOrderItem(itemId = 1L, name = "Cup", price = BigDecimal("4.00"), quantity = 5f), + ) + val order = createOrder(items) + val refunds = listOf( + createRefund( + id = 1L, + items = listOf(createRefundItem(orderItemId = 1L, quantity = 1, total = BigDecimal("4.00"))) + ), + createRefund( + id = 2L, + items = listOf(createRefundItem(orderItemId = 1L, quantity = 2, total = BigDecimal("8.00"))) + ), + ) + + // WHEN + val result = sut(order, refunds) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().quantity).isEqualTo(2f) + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGroupRefundedItemsTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGroupRefundedItemsTest.kt new file mode 100644 index 00000000000..f2e4485bd0b --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosGroupRefundedItemsTest.kt @@ -0,0 +1,161 @@ +package com.woocommerce.android.ui.woopos.orders.details + +import com.woocommerce.android.model.Refund +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.math.BigDecimal +import java.util.Date + +class WooPosGroupRefundedItemsTest { + + private val sut = WooPosGroupRefundedItems() + + private fun createRefundItem( + orderItemId: Long, + productId: Long = 10L, + quantity: Int = 1, + total: BigDecimal = BigDecimal("10.00"), + ) = Refund.Item( + productId = productId, + quantity = quantity, + orderItemId = orderItemId, + name = "Refund Product", + total = total, + price = if (quantity > 0) total / quantity.toBigDecimal() else total, + ) + + private fun createRefund( + id: Long = 1L, + items: List, + ) = Refund( + id = id, + dateCreated = Date(), + amount = items.fold(BigDecimal.ZERO) { acc, item -> acc + item.total }, + reason = null, + automaticGatewayRefund = false, + items = items, + shippingLines = emptyList(), + feeLines = emptyList(), + ) + + @Test + fun `given single refund with single item, when invoked, then correct grouping`() { + // GIVEN + val refunds = listOf( + createRefund( + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 2, total = BigDecimal("8.00")) + ) + ) + ) + + // WHEN + val result = sut(refunds) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().orderItemId).isEqualTo(1L) + assertThat(result.first().quantity).isEqualTo(2) + assertThat(result.first().total).isEqualByComparingTo(BigDecimal("8.00")) + assertThat(result.first().productId).isEqualTo(10L) + } + + @Test + fun `given multiple refunds for same item, when invoked, then quantities and totals summed`() { + // GIVEN + val refunds = listOf( + createRefund( + id = 1L, + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")) + ) + ), + createRefund( + id = 2L, + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 2, total = BigDecimal("8.00")) + ) + ), + ) + + // WHEN + val result = sut(refunds) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().quantity).isEqualTo(3) + assertThat(result.first().total).isEqualByComparingTo(BigDecimal("12.00")) + } + + @Test + fun `given multiple refunds for same item, when grouped, then price is recomputed from total and quantity`() { + // GIVEN + val refunds = listOf( + createRefund( + id = 1L, + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")) + ) + ), + createRefund( + id = 2L, + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 2, total = BigDecimal("8.00")) + ) + ), + ) + + // WHEN + val result = sut(refunds) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().quantity).isEqualTo(3) + assertThat(result.first().total).isEqualByComparingTo(BigDecimal("12.00")) + assertThat(result.first().price).isEqualByComparingTo(BigDecimal("4.00")) + } + + @Test + fun `given multiple different items, when invoked, then separate entries`() { + // GIVEN + val refunds = listOf( + createRefund( + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")), + createRefundItem(orderItemId = 2L, productId = 20L, quantity = 1, total = BigDecimal("6.00")), + ) + ) + ) + + // WHEN + val result = sut(refunds) + + // THEN + assertThat(result).hasSize(2) + assertThat(result.map { it.orderItemId }).containsExactly(1L, 2L) + assertThat(result.map { it.productId }).containsExactly(10L, 20L) + } + + @Test + fun `given empty refunds, when invoked, then empty result`() { + // WHEN + val result = sut(emptyList()) + + // THEN + assertThat(result).isEmpty() + } + + @Test + fun `given refunds with no items, when invoked, then empty result`() { + // GIVEN + val refunds = listOf( + createRefund(items = emptyList()) + ) + + // WHEN + val result = sut(refunds) + + // THEN + assertThat(result).isEmpty() + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapperTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapperTest.kt index c62db3f1fbe..a8025ef6686 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapperTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/details/WooPosOrderDetailsMapperTest.kt @@ -1,11 +1,14 @@ package com.woocommerce.android.ui.woopos.orders.details import com.woocommerce.android.model.Order +import com.woocommerce.android.model.Refund import com.woocommerce.android.ui.orders.OrderTestUtils +import com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById import com.woocommerce.android.ui.woopos.orders.OrderStatusColorKey import com.woocommerce.android.ui.woopos.orders.PosOrderStatus import com.woocommerce.android.ui.woopos.orders.RefundsFetchResult import com.woocommerce.android.ui.woopos.orders.WooPosOrderActionsProvider +import com.woocommerce.android.ui.woopos.orders.WooPosOrdersState.OrderDetailsViewState.Computed.Details.LineItemsState import com.woocommerce.android.ui.woopos.orders.WooPosOrdersState.OrderDetailsViewState.Computed.Details.TotalsBreakdown import com.woocommerce.android.ui.woopos.orders.details.refund.RefundInfo import com.woocommerce.android.ui.woopos.orders.details.refund.WooPosRefundInfoBuilder @@ -23,17 +26,20 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.wordpress.android.util.DateTimeUtils import java.math.BigDecimal +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class WooPosOrderDetailsMapperTest : BaseUnitTest() { private val resourceProvider: ResourceProvider = mock() - private val getProductById: com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById = mock() + private val getProductById: WooPosGetProductById = mock() private val formatPrice: WooPosFormatPrice = mock() private val orderStatusMapper: WooPosOrderStatusMapper = mock() private val refundInfoBuilder: WooPosRefundInfoBuilder = mock() private val orderActionsProvider: WooPosOrderActionsProvider = mock() private val bookingInfoMapper: WooPosBookingInfoMapper = mock() + private val getNonRefundedItems = WooPosGetNonRefundedItems() + private val groupRefundedItems = WooPosGroupRefundedItems() private val sut = WooPosOrderDetailsMapper( resourceProvider = resourceProvider, @@ -43,6 +49,8 @@ class WooPosOrderDetailsMapperTest : BaseUnitTest() { refundInfoBuilder = refundInfoBuilder, orderActionsProvider = orderActionsProvider, bookingInfoMapper = bookingInfoMapper, + getNonRefundedItems = getNonRefundedItems, + groupRefundedItems = groupRefundedItems, ) private val paidOrder: Order = OrderTestUtils.generateTestOrder().copy( @@ -76,6 +84,71 @@ class WooPosOrderDetailsMapperTest : BaseUnitTest() { Unit } + private suspend fun setupDefaults() { + whenever(formatPrice(any(), any())).thenAnswer { invocation -> + val amount = invocation.arguments[0] as? BigDecimal + amount?.let { "$${it.setScale(2)}" } ?: "$0.00" + } + whenever(formatPrice(any())).thenAnswer { invocation -> + val amount = invocation.arguments[0] as? BigDecimal + amount?.let { "$${it.setScale(2)}" } ?: "$0.00" + } + } + + private fun createOrderItem( + itemId: Long, + productId: Long = 10L, + name: String = "Product $itemId", + price: BigDecimal = BigDecimal("10.00"), + quantity: Float = 1f, + ) = Order.Item( + itemId = itemId, + productId = productId, + name = name, + price = price, + sku = "", + quantity = quantity, + subtotal = price * quantity.toBigDecimal(), + subtotalTax = BigDecimal.ZERO, + totalTax = BigDecimal.ZERO, + total = price * quantity.toBigDecimal(), + variationId = 0, + attributesList = emptyList(), + ) + + private fun createRefundItem( + orderItemId: Long, + productId: Long = 10L, + quantity: Int = 1, + total: BigDecimal = BigDecimal("10.00"), + name: String = "Refund Product", + ) = Refund.Item( + productId = productId, + quantity = quantity, + orderItemId = orderItemId, + name = name, + total = total, + price = if (quantity > 0) total / quantity.toBigDecimal() else total, + ) + + private fun createRefund( + id: Long = 1L, + items: List, + amount: BigDecimal = items.fold(BigDecimal.ZERO) { acc, item -> acc + item.total }, + ) = Refund( + id = id, + dateCreated = Date(), + amount = amount, + reason = null, + automaticGatewayRefund = false, + items = items, + shippingLines = emptyList(), + feeLines = emptyList(), + ) + + private fun createOrder(items: List) = + OrderTestUtils.generateTestOrder().copy(items = items) + @Test fun `given order is paid, when mapOrderDetails, then totalPaid equals formatted total`() = testBlocking { whenever(formatPrice(paidOrder.total, paidOrder.currency)).thenReturn("$106.00") @@ -117,4 +190,308 @@ class WooPosOrderDetailsMapperTest : BaseUnitTest() { assertThat(result.totalPaid).isEqualTo("$0.00") } + + @Test + fun `given refunds with items, when mapOrderDetails, then refundedLineItems are populated`() = testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00"), quantity = 2f), + ) + val order = createOrder(orderItems) + val refunds = listOf( + createRefund( + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")) + ) + ) + ) + val refundResult = RefundsFetchResult.Success(refunds) + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + val refundedItems = (result.refundedLineItems as LineItemsState.Loaded).items + assertThat(refundedItems).hasSize(1) + val refundedItem = refundedItems.first() + assertThat(refundedItem.name).isEqualTo("Cup") + assertThat(refundedItem.qtyAndUnitPrice).isEqualTo("1 x $4.00") + assertThat(refundedItem.lineTotal).isEqualTo("$-4.00") + + val lineItems = (result.lineItems as LineItemsState.Loaded).items + assertThat(lineItems).hasSize(1) + assertThat(lineItems.first().name).isEqualTo("Cup") + assertThat(lineItems.first().qtyAndUnitPrice).isEqualTo("1 x $4.00") + } + + @Test + fun `given multiple refunds for same item, when mapOrderDetails, then quantities are aggregated`() = testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00"), quantity = 3f), + ) + val order = createOrder(orderItems) + val refunds = listOf( + createRefund( + id = 1L, + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")) + ) + ), + createRefund( + id = 2L, + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 2, total = BigDecimal("8.00")) + ) + ) + ) + val refundResult = RefundsFetchResult.Success(refunds) + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + val refundedItems = (result.refundedLineItems as LineItemsState.Loaded).items + assertThat(refundedItems).hasSize(1) + val refundedItem = refundedItems.first() + assertThat(refundedItem.qtyAndUnitPrice).isEqualTo("3 x $4.00") + assertThat(refundedItem.lineTotal).isEqualTo("$-12.00") + } + + @Test + fun `given refunds with no items, when mapOrderDetails, then refundedLineItems is empty`() = testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00")), + ) + val order = createOrder(orderItems) + val refunds = listOf( + createRefund(id = 1L, items = emptyList(), amount = BigDecimal("4.00")) + ) + val refundResult = RefundsFetchResult.Success(refunds) + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + assertThat((result.refundedLineItems as LineItemsState.Loaded).items).isEmpty() + } + + @Test + fun `given refund fetch error, when mapOrderDetails, then refundedLineItems is empty`() = testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00")), + ) + val order = createOrder(orderItems) + val refundResult = RefundsFetchResult.Error + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + assertThat((result.refundedLineItems as LineItemsState.Loaded).items).isEmpty() + } + + @Test + fun `given order without refunds, when mapOrderDetailsWithoutActions, then lineItems loaded and refunds empty`() = + testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00")), + ) + val order = createOrder(orderItems) + + // WHEN + val result = sut.mapOrderDetailsWithoutActions(order) + + // THEN + val lineItems = (result.lineItems as LineItemsState.Loaded).items + assertThat(lineItems).hasSize(1) + assertThat((result.refundedLineItems as LineItemsState.Loaded).items).isEmpty() + } + + @Test + fun `given order with refunds, when mapOrderDetailsWithoutActions, then both lineItems are loading`() = + testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00")), + ) + val order = createOrder(orderItems).copy(refundTotal = BigDecimal("4.00")) + + // WHEN + val result = sut.mapOrderDetailsWithoutActions(order) + + // THEN + assertThat(result.lineItems).isInstanceOf(LineItemsState.Loading::class.java) + assertThat(result.refundedLineItems).isInstanceOf(LineItemsState.Loading::class.java) + } + + @Test + fun `given refunds for multiple items, when mapOrderDetails, then each item has separate entry`() = testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00"), quantity = 2f), + createOrderItem(itemId = 2L, productId = 20L, name = "Plate", price = BigDecimal("6.00"), quantity = 1f), + ) + val order = createOrder(orderItems) + val refunds = listOf( + createRefund( + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")), + createRefundItem(orderItemId = 2L, productId = 20L, quantity = 1, total = BigDecimal("6.00")), + ) + ) + ) + val refundResult = RefundsFetchResult.Success(refunds) + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + val refundedItems = (result.refundedLineItems as LineItemsState.Loaded).items + assertThat(refundedItems).hasSize(2) + assertThat(refundedItems.map { it.name }).containsExactly("Cup", "Plate") + assertThat(refundedItems.map { it.lineTotal }).containsExactly("$-4.00", "$-6.00") + + val lineItems = (result.lineItems as LineItemsState.Loaded).items + assertThat(lineItems).hasSize(1) + assertThat(lineItems.first().name).isEqualTo("Cup") + assertThat(lineItems.first().qtyAndUnitPrice).isEqualTo("1 x $4.00") + } + + @Test + fun `given fully refunded item, when mapOrderDetails, then item is excluded from lineItems`() = testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00"), quantity = 1f), + createOrderItem(itemId = 2L, productId = 20L, name = "Plate", price = BigDecimal("6.00"), quantity = 1f), + ) + val order = createOrder(orderItems) + val refunds = listOf( + createRefund( + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")), + ) + ) + ) + val refundResult = RefundsFetchResult.Success(refunds) + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + val lineItems = (result.lineItems as LineItemsState.Loaded).items + assertThat(lineItems).hasSize(1) + assertThat(lineItems.first().name).isEqualTo("Plate") + val refundedItems = (result.refundedLineItems as LineItemsState.Loaded).items + assertThat(refundedItems).hasSize(1) + assertThat(refundedItems.first().name).isEqualTo("Cup") + } + + @Test + fun `given no refunds, when mapOrderDetails, then all items shown in lineItems`() = testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00"), quantity = 2f), + ) + val order = createOrder(orderItems) + val refundResult = RefundsFetchResult.Success(emptyList()) + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + val lineItems = (result.lineItems as LineItemsState.Loaded).items + assertThat(lineItems).hasSize(1) + assertThat(lineItems.first().name).isEqualTo("Cup") + assertThat(lineItems.first().qtyAndUnitPrice).isEqualTo("2 x $4.00") + assertThat((result.refundedLineItems as LineItemsState.Loaded).items).isEmpty() + } + + @Test + fun `given refunded item not found in order, when buildRefundedLineItems, then refund item name is used as fallback`() = + testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem(itemId = 1L, productId = 10L, name = "Cup", price = BigDecimal("4.00"), quantity = 1f), + ) + val order = createOrder(orderItems) + val refunds = listOf( + createRefund( + items = listOf( + createRefundItem( + orderItemId = 999L, + productId = 99L, + quantity = 1, + total = BigDecimal("5.00"), + name = "Deleted Product" + ) + ) + ) + ) + val refundResult = RefundsFetchResult.Success(refunds) + + // WHEN + val result = sut.buildRefundedLineItems(order, refundResult) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first().name).isEqualTo("Deleted Product") + } + + @Test + fun `given same product in different line items, when one is refunded, then only that line item is affected`() = + testBlocking { + // GIVEN + setupDefaults() + val orderItems = listOf( + createOrderItem( + itemId = 1L, + productId = 10L, + name = "Cup (Red)", + price = BigDecimal("4.00"), + quantity = 1f + ), + createOrderItem( + itemId = 2L, + productId = 10L, + name = "Cup (Blue)", + price = BigDecimal("4.00"), + quantity = 1f + ), + ) + val order = createOrder(orderItems) + val refunds = listOf( + createRefund( + items = listOf( + createRefundItem(orderItemId = 1L, productId = 10L, quantity = 1, total = BigDecimal("4.00")) + ) + ) + ) + val refundResult = RefundsFetchResult.Success(refunds) + + // WHEN + val result = sut.mapOrderDetails(order, refundResult) + + // THEN + val lineItems = (result.lineItems as LineItemsState.Loaded).items + assertThat(lineItems).hasSize(1) + assertThat(lineItems.first().name).isEqualTo("Cup (Blue)") + + val refundedItems = (result.refundedLineItems as LineItemsState.Loaded).items + assertThat(refundedItems).hasSize(1) + assertThat(refundedItems.first().name).isEqualTo("Cup (Red)") + } }