Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions Modules/Sources/Yosemite/Stores/Helpers/OrderStoreMethods.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation
import Networking
import Storage

/// OrderStoreMethods extracts functionality of OrderStore that needs be reused within Yosemite
/// OrderStoreMethods is intentionally internal not to be exposed outside the module
///
/// periphery: ignore
internal protocol OrderStoreMethodsProtocol {
func deleteOrder(siteID: Int64,
order: Order,
deletePermanently: Bool,
onCompletion: @escaping (Result<Order, Error>) -> Void)
}

internal class OrderStoreMethods: OrderStoreMethodsProtocol {
private let remote: OrdersRemote
private let storageManager: StorageManagerType

init(
storageManager: StorageManagerType,
remote: OrdersRemote
) {
self.remote = remote
self.storageManager = storageManager
}

/// Deletes a given order.
/// Extracted from OrderStore.deleteOrder() implementation.
///
func deleteOrder(siteID: Int64, order: Order, deletePermanently: Bool, onCompletion: @escaping (Result<Order, Error>) -> Void) {
// Optimistically delete the order from storage
deleteStoredOrder(siteID: siteID, orderID: order.orderID)

remote.deleteOrder(for: siteID, orderID: order.orderID, force: deletePermanently) { [weak self] result in
switch result {
case .success:
onCompletion(result)
case .failure:
// Revert optimistic deletion unless the order is an auto-draft (shouldn't be stored)
guard order.status != .autoDraft else {
return onCompletion(result)
}
self?.upsertStoredOrdersInBackground(readOnlyOrders: [order], onCompletion: {
onCompletion(result)
})
}
}
}
}

// MARK: - Storage Methods

private extension OrderStoreMethods {
/// Deletes any Storage.Order with the specified OrderID
/// Extracted from OrderStore.deleteStoredOrder()
///
func deleteStoredOrder(siteID: Int64, orderID: Int64, onCompletion: (() -> Void)? = nil) {
storageManager.performAndSave({ storage in
guard let order = storage.loadOrder(siteID: siteID, orderID: orderID) else {
return
}
storage.deleteObject(order)
}, completion: onCompletion, on: .main)
}

/// Updates (OR Inserts) the specified ReadOnly Order Entities *in a background thread*.
/// Extracted from OrderStore.upsertStoredOrdersInBackground()
///
func upsertStoredOrdersInBackground(readOnlyOrders: [Networking.Order],
removeAllStoredOrders: Bool = false,
onCompletion: (() -> Void)? = nil) {
storageManager.performAndSave({ [weak self] derivedStorage in
guard let self else { return }
if removeAllStoredOrders {
derivedStorage.deleteAllObjects(ofType: Storage.Order.self)
}
upsertStoredOrders(readOnlyOrders: readOnlyOrders, in: derivedStorage)
}, completion: onCompletion, on: .main)
}

/// Updates (OR Inserts) the specified ReadOnly Order Entities into the Storage Layer.
/// Extracted from OrderStore.upsertStoredOrders()
///
func upsertStoredOrders(readOnlyOrders: [Networking.Order],
insertingSearchResults: Bool = false,
in storage: StorageType) {
let useCase = OrdersUpsertUseCase(storage: storage)
useCase.upsert(readOnlyOrders, insertingSearchResults: insertingSearchResults)
}
}
15 changes: 14 additions & 1 deletion Modules/Sources/Yosemite/Stores/OrderStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ import Storage

// MARK: - OrderStore
//
/// periphery: ignore
public class OrderStore: Store {
private let remote: OrdersRemote
private let methods: OrderStoreMethods

init(dispatcher: Dispatcher,
storageManager: StorageManagerType,
network: Network,
remote: OrdersRemote) {
self.remote = remote
self.methods = OrderStoreMethods(storageManager: storageManager, remote: remote)
super.init(dispatcher: dispatcher, storageManager: storageManager, network: network)
}

public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) {
self.remote = OrdersRemote(network: network)
self.methods = OrderStoreMethods(storageManager: storageManager, remote: self.remote)
super.init(dispatcher: dispatcher, storageManager: storageManager, network: network)
}

Expand Down Expand Up @@ -85,7 +97,7 @@ public class OrderStore: Store {
case let .markOrderAsPaidLocally(siteID, orderID, datePaid, onCompletion):
markOrderAsPaidLocally(siteID: siteID, orderID: orderID, datePaid: datePaid, onCompletion: onCompletion)
case let .deleteOrder(siteID, order, deletePermanently, onCompletion):
deleteOrder(siteID: siteID, order: order, deletePermanently: deletePermanently, onCompletion: onCompletion)
methods.deleteOrder(siteID: siteID, order: order, deletePermanently: deletePermanently, onCompletion: onCompletion)
case let .observeInsertedOrders(siteID, completion):
observeInsertedOrders(siteID: siteID, completion: completion)
case let .checkIfStoreHasOrders(siteID, completion):
Expand All @@ -97,6 +109,7 @@ public class OrderStore: Store {

// MARK: - Services!
//
/// periphery: ignore
private extension OrderStore {

/// Nukes all of the Stored Orders.
Expand Down
44 changes: 44 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSOrderManagementService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation
import Networking
import protocol Storage.StorageManagerType
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

/// Deletes an order permanently or moves it to trash
/// - Parameters:
/// - siteID: The site ID where the order belongs
/// - order: The order to delete
/// - deletePermanently: Whether to delete permanently or move to trash
/// - onCompletion: Completion handler with the result
func deleteOrder(siteID: Int64, order: Order, deletePermanently: Bool, onCompletion: @escaping (Result<Order, Error>) -> Void)
}
/// periphery: ignore
public final class POSOrderManagementService: POSOrderManagementServiceProtocol {
private let orderStoreMethods: OrderStoreMethodsProtocol

public convenience init?(siteID: Int64,
credentials: Credentials?,
selectedSite: AnyPublisher<JetpackSite?, Never>,
appPasswordSupportState: AnyPublisher<Bool, Never>,
storageManager: StorageManagerType) {
guard let credentials else {
DDLogError("⛔️ Could not create POSOrderManagementService due to not finding credentials")
return nil
}
let network = AlamofireNetwork(credentials: credentials,
selectedSite: selectedSite,
appPasswordSupportState: appPasswordSupportState)
let remote = OrdersRemote(network: network)
self.init(storageManager: storageManager, remote: remote)
}

public init(storageManager: StorageManagerType, remote: OrdersRemote) {
self.orderStoreMethods = OrderStoreMethods(storageManager: storageManager, remote: remote)
}

// MARK: - Protocol conformance
public func deleteOrder(siteID: Int64, order: Order, deletePermanently: Bool, onCompletion: @escaping (Result<Order, Error>) -> Void) {
orderStoreMethods.deleteOrder(siteID: siteID, order: order, deletePermanently: deletePermanently, onCompletion: onCompletion)
}
}
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
23.4
-----
- [*] Order details: Fixes broken country selector navigation and "Non editable banner" width. [https://github.com/woocommerce/woocommerce-ios/pull/16171]
- [*] Point of Sale: Remove temporary orders from storage on exiting POS mode [https://github.com/woocommerce/woocommerce-ios/pull/15975]

23.3
-----
Expand Down
14 changes: 14 additions & 0 deletions WooCommerce/Classes/POS/Adaptors/POSServiceLocatorAdaptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Yosemite
import protocol Experiments.FeatureFlagService
import enum Experiments.FeatureFlag
import protocol Storage.StorageManagerType

final class POSServiceLocatorAdaptor: POSDependencyProviding {
var analytics: POSAnalyticsProviding {
POSAnalyticsAdaptor()
Expand All @@ -29,6 +30,10 @@ final class POSServiceLocatorAdaptor: POSDependencyProviding {
var externalViews: POSExternalViewProviding {
POSExternalViewAdaptor()
}

var orderManagement: POSOrderManagementServiceProtocol {
POSOrderManagementServiceAdaptor()
}
}

// MARK: - Individual Service Adaptors
Expand Down Expand Up @@ -117,3 +122,12 @@ private struct POSExternalViewAdaptor: POSExternalViewProviding {
))
}
}

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 deleteOrder(siteID: Int64, order: Order, deletePermanently: Bool, onCompletion: @escaping (Result<Order, Error>) -> Void) {
let action = OrderAction.deleteOrder(siteID: siteID, order: order, deletePermanently: deletePermanently, onCompletion: onCompletion)
Task { @MainActor in
ServiceLocator.stores.dispatch(action)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import enum WooFoundation.CurrencyCode
import protocol WooFoundation.Analytics
import enum Alamofire.AFError
import class Yosemite.OrderTotalsCalculator
import protocol Yosemite.POSOrderManagementServiceProtocol

enum SyncOrderState {
case newOrder
Expand All @@ -36,7 +37,7 @@ protocol PointOfSaleOrderControllerProtocol {
@discardableResult
func syncOrder(for cart: Cart, retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error>
func sendReceipt(recipientEmail: String) async throws
func clearOrder()
func clearOrder() async
func collectCashPayment(changeDueAmount: String?) async throws
}

Expand All @@ -45,11 +46,13 @@ protocol PointOfSaleOrderControllerProtocol {
receiptSender: POSReceiptSending,
currencySettingsProvider: POSCurrencySettingsProviding,
analytics: POSAnalyticsProviding,
orderManagement: POSOrderManagementServiceProtocol,
celebration: PaymentCaptureCelebrationProtocol = PaymentCaptureCelebration()) {
self.orderService = orderService
self.receiptSender = receiptSender
self.currencySettingsProvider = currencySettingsProvider
self.analytics = analytics
self.orderManagement = orderManagement
self.celebration = celebration
}

Expand All @@ -58,6 +61,7 @@ protocol PointOfSaleOrderControllerProtocol {
private let currencySettingsProvider: POSCurrencySettingsProviding
private let celebration: PaymentCaptureCelebrationProtocol
private let analytics: POSAnalyticsProviding
private let orderManagement: POSOrderManagementServiceProtocol

private(set) var orderState: PointOfSaleInternalOrderState = .idle
private var order: Order? = nil
Expand Down Expand Up @@ -114,11 +118,24 @@ protocol PointOfSaleOrderControllerProtocol {
try await receiptSender.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail)
}

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

await clearAutoDraftIfNeeded(for: order)
order = nil
orderState = .idle
}

private func clearAutoDraftIfNeeded(for order: Order?) async {
guard let order, order.status == .autoDraft else { return }

await withCheckedContinuation { continuation in
Task { @MainActor in
orderManagement.deleteOrder(siteID: order.siteID, order: order, deletePermanently: true) { _ in
continuation.resume()
}
}
}
}

private func celebrate() {
celebration.celebrate()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ extension PointOfSaleAggregateModel {

func startNewCart() {
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?

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.

}
setStateForEditing()
viewStateCoordinator.reset()
}
Expand Down Expand Up @@ -613,7 +615,9 @@ extension PointOfSaleAggregateModel {

// Before exiting Point of Sale, we warn the merchant about losing their in-progress order.
// We need to clear it down as any accidental retention can cause issues especially when reconnecting card readers.
orderController.clearOrder()
Task {
await orderController.clearOrder()
}

// Ideally, we could rely on the POS being deallocated to cancel all these. Since we have memory leak issues,
// cancelling them explicitly helps reduce the risk of user-visible bugs while we work on the memory leaks.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ public struct PointOfSaleEntryPointView: View {
self.orderController = PointOfSaleOrderController(orderService: orderService,
receiptSender: receiptSender,
currencySettingsProvider: services.currency,
analytics: services.analytics)
analytics: services.analytics,
orderManagement: services.orderManagement)
self.settingsController = PointOfSaleSettingsController(siteID: siteID,
settingsService: settingsService,
cardPresentPaymentService: cardPresentPaymentService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import enum Experiments.FeatureFlag
import struct Yosemite.Coupon
import enum Yosemite.CouponDiscountType
import enum Yosemite.POSItem
import protocol Yosemite.POSOrderManagementServiceProtocol

/// POSDepenencyProviding is part of the POS entry point that defines the external dependencies from the Woo app that POS depends on

Expand Down Expand Up @@ -72,4 +73,5 @@ public protocol POSDependencyProviding {
var connectivity: POSConnectivityProviding { get }
var externalNavigation: POSExternalNavigationProviding { get }
var externalViews: POSExternalViewProviding { get }
var orderManagement: POSOrderManagementServiceProtocol { get }
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol {

func sendReceipt(recipientEmail: String) async throws { }

func clearOrder() { }
func clearOrder() async { }

func collectCashPayment(changeDueAmount: String?) async throws {}
}
Expand Down
8 changes: 8 additions & 0 deletions WooCommerce/Classes/POS/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import protocol Yosemite.POSItemFetchAnalyticsTracking
import protocol Yosemite.POSOrderListFetchStrategyFactoryProtocol
import protocol Yosemite.POSOrderListFetchStrategy
import protocol Yosemite.PointOfSaleCouponFetchStrategyFactoryProtocol
import protocol Yosemite.POSOrderManagementServiceProtocol

// MARK: - PreviewProvider helpers
//
Expand Down Expand Up @@ -537,6 +538,13 @@ final class POSPreviewServices: POSDependencyProviding {
var connectivity: POSConnectivityProviding = EmptyPOSConnectivityProvider()
var externalNavigation: POSExternalNavigationProviding = EmptyPOSExternalNavigation()
var externalViews: POSExternalViewProviding = EmptyPOSExternalView()
var orderManagement: POSOrderManagementServiceProtocol = POSPreviewOrderManagementService()
}

final class POSPreviewOrderManagementService: POSOrderManagementServiceProtocol {
func deleteOrder(siteID: Int64, order: Order, deletePermanently: Bool, onCompletion: @escaping (Result<Order, Error>) -> Void) {
onCompletion(.success(order))
}
}

// MARK: - Preview Catalog Services
Expand Down
Loading