Skip to content

Conversation

iamgabrielma
Copy link
Contributor

@iamgabrielma iamgabrielma commented Aug 5, 2025

Closes WOOMOB-88

Description

This PR addresses .autodraft orders not being deleted from storage when we exit POS after checkout, but before completing a payment.

The draft order is created in local storage when we perform POSOrderService.syncOrder() as a side effect of fetching the order remotely and then persisting it to storage temporarily in the background, when we're processing a card payment.

This should be cleared after collecting a payment and starting a new order, however it will remain visible if we just exit POS without clearing it explicitly (we only clear the in-memory order, not the autodraft created in local storage), which will remain until we pull to refresh in the order list.

Changes

  • The fix is simply to trigger the order deletion when we exit POS if the order is set to .autoDraft status
  • In order to support this without breaking modularity (DI StoresManager into POS), we:
    • Create a OrderStoreMethods and OrderStoreMethodsProtocol for shared functionality around dispatching order actions.
    • Create a separate POSOrderManagementService service to handle the order actions, abstracted from the app action dispatcher
    • Delete the order through this service in the POS order controller.

Steps to reproduce / Testing information

  • Using a physical device, enter POS and connect the card reader (physical reader is necessary)
  • Add some products to the order and checkout
  • Before collecting payment, exit POS and navigate to Orders
  • If you are fast enough, you'll see the draft disappear when the order view is loaded, otherwise you should not see the draft order.
  • On a new order, confirm that can be completed via card payment, and we can start to a new order normally.
autodraft.orders.autoremoved.mp4

  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

@iamgabrielma iamgabrielma added type: task An internally driven task. feature: order creation All tasks related to creating an order feature: POS Bug labels Aug 5, 2025
@iamgabrielma iamgabrielma added this to the 23.0 milestone Aug 5, 2025
@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Aug 5, 2025

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

App NameWooCommerce iOS Prototype
Build Numberpr15975-dcc23c6
Version23.3
Bundle IDcom.automattic.alpha.woocommerce
Commitdcc23c6
Installation URL34b5jhau5jqdo
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@iamgabrielma iamgabrielma modified the milestones: 23.0, 23.1 Aug 6, 2025
@wpmobilebot wpmobilebot modified the milestones: 23.1, 23.2 Aug 22, 2025
@wpmobilebot
Copy link
Collaborator

Version 23.1 has now entered code-freeze, so the milestone of this PR has been updated to 23.2.

@wpmobilebot wpmobilebot modified the milestones: 23.2, 23.3 Sep 5, 2025
@wpmobilebot
Copy link
Collaborator

Version 23.2 has now entered code-freeze, so the milestone of this PR has been updated to 23.3.

@iangmaia iangmaia modified the milestones: 23.3, 23.4 Sep 19, 2025
@iangmaia
Copy link
Contributor

Version 23.3 has now entered code-freeze, so the milestone of this PR has been updated to 23.4.

@iamgabrielma iamgabrielma removed this from the 23.4 milestone Sep 29, 2025
@iamgabrielma iamgabrielma marked this pull request as ready for review September 30, 2025 13:49
@iamgabrielma iamgabrielma added this to the 23.4 milestone Sep 30, 2025

func waitForClearOrder() async {
while !clearOrderWasCalled {
try? await Task.sleep(nanoseconds: 1_000_000)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

A question/feedback about this one: I've always though that using Task.sleep would be considered not the best practice, however we seem to use it scarcely in this test suite as well as other POS tests to handle testing async operations in mocks.

My first approach was just to add the waiting time to the tests itself, but updating the mock instead seemed cleaner, since the SUT is the aggregate model, and not the order controller.

I've also tried to use the confirmation API, but I got nowhere, so to avoid too many additional changes I decided that should be fine to add a small waiting time to account for the clearOrder func being now async.

Let me know if you have any thoughts about it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Generally, any sort of timeout in test can cause flakiness and (by design) makes tests slower. I would encourage us to find other ways to test without them if possible.

I will check if I have any ideas for this case!

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, it can be tested without timeouts, but I agree it can be tricky with Swift Testing when mixing synchronous and asynchronous code.

First, we want to know when clearOrder was called. Since clearOrder is async, we need some mechanism to let us know when something happens asynchronously. The easiest one to use in mocks is a callback.

Second, we need to wait for tests until our async condition is met. You're right, we cannot use the confirmation API. I jumped on it many times. It can only be used if the method we're testing is async. In our case, neither sut.startNewCart()nor sut.pointOfSaleClosed() are async. You were very close to the solution based on the code, in these cases we need to use withCheckedContinuation().

Therefore, in our mocks we should have:

    var clearOrderWasCalled: Bool = false
    var onClearOrderCalled: () -> Void = { }

    func clearOrder() async {
        clearOrderWasCalled = true
        onClearOrderCalled()
    }

in our tests we need to have:

sut.addToCart(makePurchasableItem())

            await withCheckedContinuation { continuation in
                orderController.onClearOrderCalled = {
                    continuation.resume()
                }

                // When
                sut.startNewCart()
            }

            // Then
            #expect(orderController.clearOrderWasCalled == true)

That's it! Here's the commit: https://github.com/woocommerce/woocommerce-ios/compare/task/WOOMOB-88-ensure-autodraft-order-removal-on-exit-POS...task/WOOMOB-88-ensure-autodraft-order-removal-on-exit-POS-with-async-tests?expand=1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's very helpful, appreciated 🙇 . It's definitely tricky to wrap my head around about the flow here: enters the continuation block -> set the callback -> start new cart (which spawns async task) -> suspend continuation -> execute clear order -> trigger callback -> resume the continuation

Copy link
Contributor

@staskus staskus left a comment

Choose a reason for hiding this comment

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

Thank you for working on it.

There's one more case to handle, a few places to clean up (I think adaptors can and should be avoided), timeouts to remove.

I was also wondering if we could leave this behavior within the payment code. E.g. cleaning the order if payment was canceled. Not sure if it's the option but if that would be possible, I think that would be the cleanest solution hiding any complexity from the client of the payment code (POS).

import struct Combine.AnyPublisher
import struct NetworkingCore.JetpackSite

public protocol POSOrderManagementServiceProtocol {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we use POSOrderService instead? I found it a bit counter-intuitive to encounter a different kind of order service or is there a good reason?

Copy link
Contributor Author

@iamgabrielma iamgabrielma Oct 3, 2025

Choose a reason for hiding this comment

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

Great point. I don't have a good reason to keep it separate. It also makes more more sense to keep the order deletion action in the POSOrderService along with the other order-related functions. Updated on 040be92

removeAllItemsFromCart()
orderController.clearOrder()
Task {
await orderController.clearOrder()
Copy link
Contributor

Choose a reason for hiding this comment

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

The draft order is created in local storage <...> as a side effect of fetching the order remotely and then persisting it to storage temporarily in the background, when we're processing a card payment.

Since the order is put into storage as a site effect when processing a card payment, could it also be cleared at the same level? My concern is that we may be solving the issue at the wrong abstraction level. While some code outside the POS puts the order into storage, the POS itself clears the storage. If something changes in the outside code, we may continue to delete the order without actually needing to do it.

What are your thoughts on that, would that be doable?

}

func clearOrder() {
func clearOrder() async {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should also clear the order when coming back to the cart, because we create a new one every time we check out.

Simulator.Screen.Recording.-.iPad.Air.11-inch.M3.-.2025-10-01.at.18.36.00.mov

}
}

private struct POSOrderManagementServiceAdaptor: POSOrderManagementServiceProtocol {
Copy link
Contributor

Choose a reason for hiding this comment

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

This code can be removed, no need to use the adaptors or store dispatching. This is only required for dependencies that cannot be injected in any other way, and we prefer to use them through the Environment.

For our case:

  • POSOrderManagementService relies on OrderStoreMethodsProtocol
  • POSOrderManagementService can be created within POSTabCoordinator (like PointOfSaleCouponService), injecting methods
  • POSOrderManagementService can be passed to entry point view, which can then be passed to PointOfSaleOrderController.


func waitForClearOrder() async {
while !clearOrderWasCalled {
try? await Task.sleep(nanoseconds: 1_000_000)
Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, it can be tested without timeouts, but I agree it can be tricky with Swift Testing when mixing synchronous and asynchronous code.

First, we want to know when clearOrder was called. Since clearOrder is async, we need some mechanism to let us know when something happens asynchronously. The easiest one to use in mocks is a callback.

Second, we need to wait for tests until our async condition is met. You're right, we cannot use the confirmation API. I jumped on it many times. It can only be used if the method we're testing is async. In our case, neither sut.startNewCart()nor sut.pointOfSaleClosed() are async. You were very close to the solution based on the code, in these cases we need to use withCheckedContinuation().

Therefore, in our mocks we should have:

    var clearOrderWasCalled: Bool = false
    var onClearOrderCalled: () -> Void = { }

    func clearOrder() async {
        clearOrderWasCalled = true
        onClearOrderCalled()
    }

in our tests we need to have:

sut.addToCart(makePurchasableItem())

            await withCheckedContinuation { continuation in
                orderController.onClearOrderCalled = {
                    continuation.resume()
                }

                // When
                sut.startNewCart()
            }

            // Then
            #expect(orderController.clearOrderWasCalled == true)

That's it! Here's the commit: https://github.com/woocommerce/woocommerce-ios/compare/task/WOOMOB-88-ensure-autodraft-order-removal-on-exit-POS...task/WOOMOB-88-ensure-autodraft-order-removal-on-exit-POS-with-async-tests?expand=1

removeAllItemsFromCart()
orderController.clearOrder()
Task {
await orderController.clearOrder()
Copy link
Contributor

Choose a reason for hiding this comment

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

Check if we don't need to capture orderController weakly here. It likely doesn't cause memory leaks but we can check just to be safe. I remember it did for some other calls that didn't complete.

@iamgabrielma iamgabrielma modified the milestones: 23.4, 23.5 Oct 3, 2025
@wpmobilebot wpmobilebot modified the milestones: 23.5, 23.6 Oct 17, 2025
@wpmobilebot
Copy link
Collaborator

Version 23.5 has now entered code-freeze, so the milestone of this PR has been updated to 23.6.

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

Labels

Bug feature: order creation All tasks related to creating an order feature: POS type: task An internally driven task.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants