Skip to content

Conversation

@samiuelson
Copy link
Contributor

@samiuelson samiuelson commented Dec 3, 2025

Description

WOOMOB-1802 part 1/2

This PR introduces foundational components for handling refunds in the Woo POS. It adds business logic to calculate refundable items from orders by accounting for previous refunds, along with the necessary ViewModel and state management for the refund UI.

Key changes:

  • Implements WooPosGetRefundableItems use case that expands order line items into individual refundable units and calculates remaining quantities after refunds
  • Adds WooPosRefundViewModel for managing refund dialog state with loading, error, and success states
  • Introduces getOrderById method in WooPosOrdersDataSource with cache-first retrieval strategy

Test Steps

Images/gif

N/A

  • I have considered if this change warrants release notes and have added them to RELEASE-NOTES.txt if necessary. Use the "[Internal]" label for non-user-facing changes.

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Dec 3, 2025

📲 You can test the changes from this Pull Request in WooCommerce-Wear Android by scanning the QR code below to install the corresponding build.
App NameWooCommerce-Wear Android
Platform⌚️ Wear OS
FlavorJalapeno
Build TypeDebug
Commit8ed119f
Direct Downloadwoocommerce-wear-prototype-build-pr15056-8ed119f.apk

@samiuelson samiuelson changed the title Add business logic for WooPos refundable items calculation [Woo POS][Refunds] Refundable items calculation Dec 3, 2025
@samiuelson samiuelson requested a review from Copilot December 3, 2025 16:33
Copilot finished reviewing on behalf of samiuelson December 3, 2025 16:36
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces foundational components for handling refunds in the WooCommerce POS system. It adds business logic to calculate refundable items from orders by accounting for previous refunds, along with the necessary ViewModel and state management for the refund UI.

Key changes include:

  • Implements WooPosGetRefundableItems use case that expands order line items into individual refundable units and calculates remaining quantities after refunds
  • Adds WooPosRefundViewModel for managing refund dialog state with loading, error, and success states
  • Introduces getOrderById method in WooPosOrdersDataSource with cache-first retrieval strategy

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
WooPosGetRefundableItems.kt Core business logic for calculating refundable items with unit price/tax calculations
WooPosGetRefundableItemsTest.kt Comprehensive test suite covering basic cases, refund handling, variations, price calculations, and edge cases
WooPosRefundViewModel.kt ViewModel coordinating order/refund retrieval and building UI state
WooPosRefundState.kt Sealed class defining Loading, Content, Error, and NoRefundableItems states
WooPosRefundableItem.kt Immutable data model representing a single refundable unit
WooPosOrdersDataSource.kt Adds cache-first order retrieval by ID
WooPosOrdersDetails.kt Adds width modifier import for UI layout

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Dec 3, 2025

📲 You can test the changes from this Pull Request in WooCommerce Android by scanning the QR code below to install the corresponding build.

App NameWooCommerce Android
Platform📱 Mobile
FlavorJalapeno
Build TypeDebug
Commit8ed119f
Direct Downloadwoocommerce-prototype-build-pr15056-8ed119f.apk

@samiuelson samiuelson added this to the 23.9 milestone Dec 3, 2025
@samiuelson samiuelson added feature: POS type: task An internally driven task. labels Dec 3, 2025
- Create new test class `WooPosRefundViewModelTest`.
- Add tests to verify `WooPosRefundViewModel` initialization and state management (`Loading`, `Content`, `Error`, `NoRefundableItems`).
- Verify calculations for subtotals, taxes, and totals with multiple refundable items.
- Ensure correct handling of order and refund data retrieval failures.
Update the filtering condition when mapping product items to check against `orderItemId` instead of `productId` and `variationId`. This ensures that the correct specific line item is matched when calculating refunded quantities, preventing issues with products sharing IDs across different line items.
@samiuelson samiuelson marked this pull request as ready for review December 3, 2025 20:48
@dangermattic
Copy link
Collaborator

dangermattic commented Dec 4, 2025

1 Warning
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

@malinajirka malinajirka self-assigned this Dec 4, 2025
@samiuelson samiuelson added the status: do not merge Dependent on another PR, ready for review but not ready for merge. label Dec 4, 2025
@samiuelson
Copy link
Contributor Author

I'm adding a "do not merge" label to prevent merging until the parent branch is trunk.

Copy link
Contributor

@malinajirka malinajirka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @samiuelson !

I haven't found anything blocking, but I'm personally a bit worried about the local calculations. I think we might want to consider updating/introducing APIs that would give us all data we need, so we can avoid any local calculations.
Doing local calculations has several risks:

  • Rounding issues - we have been dealing with these as long as I can remember in Woo.
  • Extensions/Hooks - there might be hooks that modify/change what can or can't be refunded (I'm not sure if they exist, but even if they don't, anyone can introduce them at any point).
  • API-non-breaking changes in core might break the apps.

Having said that, I don't want to block progress of this project. If this is the approach we decided for, then lets go with it.

refunds: List<Refund>,
productItems: List<Order.Item>
): Map<Long, Float> {
val allRefundedItems = refunds.flatMap { it.items }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 It seems we are iterating over allRefundedItems for each item. I guess performance is not an issue here, but we can consider simplifying this to something like:

private fun calculateMaxRefundQuantities(
    refunds: List<Refund>,
    productItems: List<Order.Item>
): Map<Long, Float> {
    val refundedByItemId = refunds
        .flatMap { it.items }
        .groupingBy { it.orderItemId }
        .fold(0f) { acc, item -> acc + item.quantity }

    return productItems
        .associate { it.itemId to (it.quantity - (refundedByItemId[it.itemId] ?: 0f)) }
        .filterValues { it > 0 }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea. Applied: 8d8ce37

return if (item.quantity == 0f) {
item.total
} else {
item.total.divide(item.quantity.toBigDecimal(), 2, RoundingMode.HALF_UP)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ I realize this might not have an easy solution, but this feels extremely risky. WooCommerce uses 2 decimal places as default for USD, but not necessarily for all languages. Moreover, it's a setting that can be changed.

Is there perhaps a solution in which we would avoid doing local calculations like this?

If not, I think we might still want to explore if we could fetch the decimal-places-setting and use that instead of hardcoding 2. We also need to be careful to do the rounding the same way backend does - by row vs by item vs by total => each approach yields different results.

P.S. We have had several rounding issues in the app. I remember at least one case in which it was impossible to refund an order, since the app calculated the total with 0.01 difference compared to backend and the backend refused the refund. So I believe this is not just a theoretical issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, thanks 👍 I updated this to use orderItem.price, which matches the logic in store management: 5092da9

Now, only the tax calculation relies on local calculation (the same as in store management), but this is limited by the current API design.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be easier to discuss on a sync call, but I'd personally consider updating the API. Not doing any local calculations was (and maybe still is) one of the stones we have been building the POS around, so if it's solvable by updating the API it might be worth considering. (If we calculate taxes just for display, but the API uses value that it calculates itself, it's not worth it - but if the API uses the value the app provides, I think it might be worth considering to update the API).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(the same as in store management)

FWIW I personally believe that the fact it's used in store management shouldn't impact our decision on how to go about it in the POS - I mean it's good to know, but the fact we do local calculations in store management shouldn't be a justification for using it in POS. You were technically not saying that it is a justification, but want to be explicit about this because I honestly believe it's important to keep the quality of the POS high - even if it means slower development, changes on backend, and limitations on out-dated versions of Woo core.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally believe that the fact it's used in store management shouldn't impact our decision on how to go about it in the POS - I mean it's good to know, but the fact we do local calculations in store management shouldn't be a justification for using it in POS.

Good call. II think it's a good idea to raise a discussion internally on how to approach the implementation. Thanks for questioning the current logic.

return Result.success(cached)
}

return refreshOrderById(orderId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ If I'm reading the content of refreshOrderById correctly, it won't update the cache with the order we fetch, right? There is updateCachedOrderIfPresent method, but this line is unreachable if the order is already in cache, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The refreshOrderById returns the fetched order, but it doesn't do an upsert. This is a pattern used in WooPosOrdersViewModel too, and I didn't want to interfere with the existing caching logic in this PR.

order: Order,
refundableItems: List<WooPosRefundableItem>
): WooPosRefundState.Content {
val subtotal = refundableItems.sumOf { it.lineTotal }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📓 Similar as above - this is technically a local calculation, if there is some solution how we could avoid it that would be best. However, if there isn't, I think we might want to do explicit rounding here (ideally, again based on the setting from Woo core). Wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function matches the logic in store management refunds (fun List<ProductRefundListItem>.calculateTotals(): Pair<BigDecimal, BigDecimal>), so I don't think it's critical. I logged an issue to improve this code: https://linear.app/a8c/issue/WOOMOB-1852/woo-posrefunds-use-decimal-places-rounding-mode-mode-from-api, and will tackle it in a separate PR.

}

@Test
fun `given order with refunds, when init, then calculates remaining refundable items correctly`() = runTest {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ It feels this test mocks refunds and then verifies whether they are as expected - in order words it tests the mock, wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed this method to given order with refunds, when init, then builds content state with refundable items: 036565e

import java.util.Date
import kotlin.test.Test

class WooPosGetRefundableItemsTest {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 I would consider cleaning up this class. Ideally, each test should set only the data that it needs in order to pass. We could for example introduce a generateOrderItem method with default values - if the test for example needs the total to be $10 it would pass just 10 to the method. This way, it would be clearer what each test is testing and each test would be significantly shorter and easier to maintain. Wdyt?

rowIndex = 0
),
WooPosRefundableItem(
orderItemId = 1L,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 It seems this orderItemId is identical to the first one, is this intentional?

P.S. We might want to consider creating a helper generate class here as well. Wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's explicit to test the scenario of the same orderItemId and different rowIndex. I cleaned up the code to make it more explicit here: 0bebfe6

Base automatically changed from woomob-1801-add-the-issue-refund-button to trunk December 4, 2025 21:26
@samiuelson samiuelson removed the status: do not merge Dependent on another PR, ready for review but not ready for merge. label Dec 4, 2025
@samiuelson samiuelson enabled auto-merge December 4, 2025 22:01
@samiuelson samiuelson merged commit d62c547 into trunk Dec 4, 2025
18 checks passed
@samiuelson samiuelson deleted the woo-pos-refundable-items-calculation branch December 4, 2025 22:21
@codecov-commenter
Copy link

Codecov Report

❌ Patch coverage is 30.76923% with 252 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.66%. Comparing base (d7c5ce2) to head (8ed119f).
⚠️ Report is 32 commits behind head on trunk.

Files with missing lines Patch % Lines
...ndroid/ui/woopos/orders/WooPosIssueRefundDialog.kt 0.00% 238 Missing ⚠️
.../common/composeui/component/WooPosDialogWrapper.kt 0.00% 5 Missing ⚠️
...android/ui/woopos/orders/WooPosOrdersDataSource.kt 0.00% 5 Missing ⚠️
...droid/ui/woopos/orders/WooPosGetRefundableItems.kt 94.87% 1 Missing and 1 partial ⚠️
.../android/ui/woopos/orders/WooPosRefundViewModel.kt 95.55% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##              trunk   #15056      +/-   ##
============================================
- Coverage     38.68%   38.66%   -0.02%     
- Complexity    10332    10362      +30     
============================================
  Files          2164     2168       +4     
  Lines        122666   123004     +338     
  Branches      16941    16977      +36     
============================================
+ Hits          47450    47561     +111     
- Misses        70413    70638     +225     
- Partials       4803     4805       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: POS type: task An internally driven task.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants