Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
20 changes: 16 additions & 4 deletions Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import class WooFoundation.CurrencyFormatter
import enum WooFoundation.CurrencyCode
import struct Combine.AnyPublisher
import struct NetworkingCore.JetpackSite
import protocol Storage.StorageManagerType

public protocol POSOrderServiceProtocol {
/// Syncs order based on the cart.
Expand All @@ -13,31 +14,38 @@ public protocol POSOrderServiceProtocol {
func syncOrder(cart: POSCart, currency: CurrencyCode) async throws -> Order
func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws
func markOrderAsCompletedWithCashPayment(order: Order, changeDueAmount: String?) async throws
func deleteOrder(siteID: Int64, order: Order, deletePermanently: Bool, onCompletion: @escaping (Result<Order, Error>) -> Void)
}

public final class POSOrderService: POSOrderServiceProtocol {
private let siteID: Int64
private let ordersRemote: POSOrdersRemoteProtocol
private let orderStoreMethods: OrderStoreMethodsProtocol

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

public init(siteID: Int64,
ordersRemote: POSOrdersRemoteProtocol) {
internal init(siteID: Int64,
ordersRemote: POSOrdersRemoteProtocol,
orderStoreMethods: OrderStoreMethodsProtocol) {
self.siteID = siteID
self.ordersRemote = ordersRemote
self.orderStoreMethods = orderStoreMethods
}

// MARK: - Protocol conformance
Expand Down Expand Up @@ -81,6 +89,10 @@ public final class POSOrderService: POSOrderServiceProtocol {
throw POSOrderServiceError.updateOrderFailed
}
}

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)
}
}

private extension Order {
Expand Down
22 changes: 22 additions & 0 deletions Modules/Tests/YosemiteTests/Mocks/MockOrderStoreMethods.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
@testable import Yosemite
import Networking

final class MockOrderStoreMethods: OrderStoreMethodsProtocol {
var deleteOrderCalled = false
var deletedOrder: Order?
var deletedSiteID: Int64?
var deletedPermanently: Bool?
var deleteOrderResult: Result<Order, Error> = .success(Order.fake())

func deleteOrder(siteID: Int64,
order: Order,
deletePermanently: Bool,
onCompletion: @escaping (Result<Order, Error>) -> Void) {
deleteOrderCalled = true
deletedOrder = order
deletedSiteID = siteID
deletedPermanently = deletePermanently
onCompletion(deleteOrderResult)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import Testing
struct POSOrderServiceTests {
let sut: POSOrderService
let mockOrdersRemote: MockPOSOrdersRemote
let mockOrderStoreMethods: MockOrderStoreMethods

init() {
let mockOrdersRemote = MockPOSOrdersRemote()
let mockOrderStoreMethods = MockOrderStoreMethods()
self.mockOrdersRemote = mockOrdersRemote
self.sut = POSOrderService(siteID: 123, ordersRemote: mockOrdersRemote)
self.mockOrderStoreMethods = mockOrderStoreMethods
self.sut = POSOrderService(siteID: 123, ordersRemote: mockOrdersRemote, orderStoreMethods: mockOrderStoreMethods)
}

@Test
Expand Down
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
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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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 Down Expand Up @@ -114,11 +114,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
orderService.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
3 changes: 2 additions & 1 deletion WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ private extension POSTabCoordinator {
let orderService = POSOrderService(siteID: siteID,
credentials: credentials,
selectedSite: defaultSitePublisher,
appPasswordSupportState: isAppPasswordSupported),
appPasswordSupportState: isAppPasswordSupported,
storageManager: storageManager),
#available(iOS 17.0, *) {
let posView = PointOfSaleEntryPointView(
siteID: siteID,
Expand Down
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
2 changes: 2 additions & 0 deletions WooCommerce/Classes/POS/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ final class POSOrderServicePreview: POSOrderServiceProtocol {
func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws {}

func markOrderAsCompletedWithCashPayment(order: Yosemite.Order, changeDueAmount: String?) async throws {}

func deleteOrder(siteID: Int64, order: Yosemite.Order, deletePermanently: Bool, onCompletion: @escaping (Result<Yosemite.Order, any Error>) -> Void) {}
}

final class POSReceiptServicePreview: POSReceiptServiceProtocol {
Expand Down
Loading