Skip to content

Commit 5aa8624

Browse files
authored
[Woo POS] Always create a new order when checking out (#15427)
2 parents 877e2c2 + 003e42e commit 5aa8624

File tree

7 files changed

+121
-258
lines changed

7 files changed

+121
-258
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
-----
66
- [*] Order Creation: Resolved an issue where the exit confirmation was not displayed when there were unsaved changes in a split view [https://github.com/woocommerce/woocommerce-ios/pull/15394].
77
- [*] Order Creation: Fixed an issue where order recalculation would stop working after canceling a confirmation with unsaved changes [https://github.com/woocommerce/woocommerce-ios/pull/15392].
8+
- [internal] POS: Always create a new order during the checkout [https://github.com/woocommerce/woocommerce-ios/pull/15427].
89
- [internal] Improve data fetching in Order Details, to avoid I/O performance on the main thread. [https://github.com/woocommerce/woocommerce-ios/pull/14999]
910

1011
22.0

WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import protocol WooFoundation.Analytics
1818

1919
enum SyncOrderState {
2020
case newOrder
21-
case orderUpdated
2221
case orderNotChanged
2322
}
2423

@@ -75,27 +74,20 @@ protocol PointOfSaleOrderControllerProtocol {
7574
}
7675

7776
orderState = .syncing
78-
let isNewOrder = order == nil
7977

8078
do {
8179
let syncedOrder = try await orderService.syncOrder(cart: posCart,
82-
order: order,
8380
currency: storeCurrency)
8481
self.order = syncedOrder
8582
orderState = .loaded(totals(for: syncedOrder), syncedOrder)
86-
if isNewOrder {
87-
analytics.track(.orderCreationSuccess)
88-
return .success(.newOrder)
89-
} else {
90-
return .success(.orderUpdated)
91-
}
83+
analytics.track(.orderCreationSuccess)
84+
return .success(.newOrder)
9285
} catch {
93-
if isNewOrder {
94-
analytics.track(event: WooAnalyticsEvent.Orders.orderCreationFailed(
95-
usesGiftCard: false,
96-
errorContext: String(describing: error),
97-
errorDescription: error.localizedDescription))
98-
}
86+
self.order = nil
87+
analytics.track(event: WooAnalyticsEvent.Orders.orderCreationFailed(
88+
usesGiftCard: false,
89+
errorContext: String(describing: error),
90+
errorDescription: error.localizedDescription))
9991
setOrderStateToError(error, retryHandler: retryHandler)
10092
return .failure(SyncOrderStateError.syncFailure)
10193
}

WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,13 @@ private extension PointOfSaleAggregateModel {
172172
}
173173
}
174174

175-
// Tracks when the order is created or updated successfully
175+
// Tracks when the order is created successfully
176176
// pdfdoF-6hn#comment-7625-p2
177177
func trackOrderSyncState(_ result: Result<SyncOrderState, Error>) {
178178
switch result {
179179
case .success(let syncState):
180180
switch syncState {
181-
case .newOrder, .orderUpdated:
181+
case .newOrder:
182182
collectOrderPaymentAnalyticsTracker.trackOrderSyncSuccess()
183183
default:
184184
break

WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,11 +299,10 @@ struct PointOfSaleOrderControllerTests {
299299
}
300300

301301
@available(iOS 17.0, *)
302-
@Test func syncOrder_when_updating_existing_order_returns_orderUpdated_result() async throws {
302+
@Test func syncOrder_when_updating_existing_order_returns_newOrder_result() async throws {
303303
// Given
304304
let sut = PointOfSaleOrderController(orderService: mockOrderService,
305305
receiptService: mockReceiptService)
306-
let fakeOrderItem = OrderItem.fake().copy(quantity: 1)
307306
let fakeOrder = Order.fake()
308307
mockOrderService.orderToReturn = fakeOrder
309308

@@ -316,9 +315,9 @@ struct PointOfSaleOrderControllerTests {
316315

317316
// Then
318317
if case .success(let state) = result {
319-
#expect(state == .orderUpdated)
318+
#expect(state == .newOrder)
320319
} else {
321-
#expect(Bool(false), "Expected success result with order updated")
320+
#expect(Bool(false), "Expected success result with new order")
322321
}
323322
}
324323

@@ -524,6 +523,51 @@ struct PointOfSaleOrderControllerTests {
524523
])
525524
}
526525

526+
@available(iOS 17.0, *)
527+
@Test func syncOrder_when_fails_sets_order_to_nil() async throws {
528+
// Given
529+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
530+
receiptService: mockReceiptService)
531+
532+
// First create a successful order
533+
let orderItem = OrderItem.fake().copy(quantity: 1)
534+
let fakeOrder = Order.fake().copy(items: [orderItem])
535+
let cartItem = makeItem(orderItemsToMatch: [orderItem])
536+
mockOrderService.orderToReturn = fakeOrder
537+
538+
// Initial sync succeeds
539+
let initialResult = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
540+
switch initialResult {
541+
case .success(.newOrder):
542+
break
543+
default:
544+
#expect(Bool(false), "Expected success with new order, got \(initialResult)")
545+
}
546+
547+
// Then simulate a failure
548+
mockOrderService.errorToReturn = SyncOrderStateError.syncFailure
549+
let failureResult = await sut.syncOrder(for: .init(items: [cartItem, cartItem]), retryHandler: {})
550+
switch failureResult {
551+
case .failure(SyncOrderStateError.syncFailure):
552+
break
553+
default:
554+
#expect(Bool(false), "Expected sync failure, got \(failureResult)")
555+
}
556+
557+
// When - try syncing with the same cart again
558+
mockOrderService.errorToReturn = nil
559+
mockOrderService.orderToReturn = fakeOrder // Restore mock to return success
560+
let subsequentResult = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
561+
562+
// Then - should be treated as new order since previous order was cleared
563+
switch subsequentResult {
564+
case .success(.newOrder):
565+
break
566+
default:
567+
#expect(Bool(false), "Expected new order after failure cleared previous order, got \(subsequentResult)")
568+
}
569+
}
570+
527571
struct AnalyticsTests {
528572
private let analytics: WooAnalytics
529573
private let analyticsProvider = MockAnalyticsProvider()

WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ class MockPOSOrderService: POSOrderServiceProtocol {
1212
var spySyncOrderCurrency: CurrencyCode?
1313

1414
func syncOrder(cart: Yosemite.POSCart,
15-
order: Yosemite.Order?,
1615
currency: CurrencyCode) async throws -> Yosemite.Order {
1716
syncOrderWasCalled = true
1817
spySyncOrderCurrency = currency

Yosemite/Yosemite/Tools/POS/POSOrderService.swift

Lines changed: 16 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ public protocol POSOrderServiceProtocol {
77
/// Syncs order based on the cart.
88
/// - Parameters:
99
/// - cart: Cart with different types of items and quantities.
10-
/// - order: Optional latest remotely synced order. Nil when syncing order for the first time.
1110
/// - Returns: Order from the remote sync.
12-
func syncOrder(cart: POSCart, order: Order?, currency: CurrencyCode) async throws -> Order
11+
func syncOrder(cart: POSCart, currency: CurrencyCode) async throws -> Order
1312
func updatePOSOrder(order: Order, recipientEmail: String) async throws
1413
}
1514

@@ -36,19 +35,14 @@ public final class POSOrderService: POSOrderServiceProtocol {
3635
// MARK: - Protocol conformance
3736

3837
public func syncOrder(cart: POSCart,
39-
order posOrder: Order?,
4038
currency: CurrencyCode) async throws -> Order {
41-
let initialOrder: Order = posOrder ?? OrderFactory.newOrder(currency: currency)
42-
.copy(siteID: siteID,
43-
status: .autoDraft)
44-
let order = updateOrder(initialOrder, cart: cart).sanitizingLocalItems()
45-
let syncedOrder: Order
46-
if posOrder != nil {
47-
syncedOrder = try await ordersRemote.updatePOSOrder(siteID: siteID, order: order, fields: [.items, .couponLines])
48-
} else {
49-
syncedOrder = try await ordersRemote.createPOSOrder(siteID: siteID, order: order, fields: [.items, .status, .currency, .couponLines])
50-
}
51-
return syncedOrder
39+
let order = OrderFactory
40+
.newOrder(currency: currency)
41+
.copy(siteID: siteID, status: .autoDraft)
42+
.addItems(cart.items)
43+
.addCoupons(cart.coupons)
44+
45+
return try await ordersRemote.createPOSOrder(siteID: siteID, order: order, fields: [.items, .status, .currency, .couponLines])
5246
}
5347

5448
public func updatePOSOrder(order: Order, recipientEmail: String) async throws {
@@ -66,73 +60,19 @@ public final class POSOrderService: POSOrderServiceProtocol {
6660
}
6761
}
6862

69-
private struct POSOrderSyncProductType: OrderSyncProductTypeProtocol, Hashable {
70-
let productID: Int64
71-
let price: String
72-
// Not used in POS but have to be included for the app usage.
73-
let productType: ProductType
74-
let bundledItems: [ProductBundleItem]
75-
76-
init(productID: Int64, price: String, productType: ProductType, bundledItems: [ProductBundleItem] = []) {
77-
self.productID = productID
78-
self.price = price
79-
self.productType = productType
80-
self.bundledItems = bundledItems
81-
}
82-
}
83-
84-
private extension POSOrderService {
85-
func updateOrder(_ order: Order, cart: POSCart) -> Order {
86-
// Removes all existing items by setting quantity to 0.
87-
let itemsToRemove = order.items.compactMap {
88-
Self.removalProductInput(item: $0)
89-
}
90-
91-
// Adds items from the latest cart grouping by item.
92-
let itemsToAdd = cart.items.createGroupedOrderSyncProductInputs().values
93-
let itemsToSync = itemsToRemove + itemsToAdd
94-
95-
var order = ProductInputTransformer.updateMultipleItems(with: itemsToSync, on: order, shouldUpdateOrDeleteZeroQuantities: .update)
96-
order = updateCoupons(cart.coupons, on: order)
97-
98-
return order
63+
private extension Order {
64+
func addItems(_ cartItems: [POSCartItem]) -> Order {
65+
let itemsToAdd = Array(cartItems.createGroupedOrderSyncProductInputs().values)
66+
return ProductInputTransformer
67+
.updateMultipleItems(with: itemsToAdd, on: self, shouldUpdateOrDeleteZeroQuantities: .update)
68+
.sanitizingLocalItems()
9969
}
10070

101-
func updateCoupons(_ coupons: [POSCoupon], on order: Order) -> Order {
102-
// Get coupon codes from cart
103-
let cartCouponCodes = Set(coupons.map { $0.code })
104-
105-
// Keep existing coupons that are still in the cart
106-
let remainingCoupons = order.coupons.filter { orderCoupon in
107-
cartCouponCodes.contains(orderCoupon.code)
108-
}
109-
110-
// Find new coupons that need to be added (in cart but not in order)
111-
let existingCouponCodes = Set(order.coupons.map { $0.code })
71+
func addCoupons(_ coupons: [POSCoupon]) -> Order {
11272
let newCoupons = coupons
113-
.filter { !existingCouponCodes.contains($0.code) }
11473
.map { OrderFactory.newOrderCouponLine(code: $0.code) }
11574

116-
// Update order with remaining + new coupons
117-
return order.copy(coupons: remainingCoupons + newCoupons)
118-
}
119-
120-
/// Creates a new `OrderSyncProductInput` type meant to remove an existing item from `OrderSynchronizer`
121-
///
122-
static func removalProductInput(item: OrderItem) -> OrderSyncProductInput? {
123-
let productForRemoval = POSProductForRemoval(productID: item.productID)
124-
// Return a new input with the new quantity but with the same item id to properly reference the update.
125-
return OrderSyncProductInput(id: item.itemID,
126-
product: .product(productForRemoval),
127-
quantity: 0)
128-
}
129-
130-
/// A simplified product struct, intended to contain only the `productID`
131-
struct POSProductForRemoval: OrderSyncProductTypeProtocol {
132-
var price: String = ""
133-
var productID: Int64
134-
var productType: ProductType = .simple
135-
var bundledItems: [ProductBundleItem] = []
75+
return self.copy(coupons: newCoupons)
13676
}
13777
}
13878

0 commit comments

Comments
 (0)