Repository: woocommerce/woocommerce-ios
Pull request #15508: [Woo POS] Create CartItem enum to simplify aggregate model interface
Author: login: staskus
Labels: name: type: task
description: An internally driven task.
name: feature: POS
Created: 2025-04-14T19:12:41Z | Merged: 2025-04-18T11:04:49Z

<!-- Remember about a good descriptive title. -->

<!-- Id number of the GitHub issue this PR addresses. -->
<!-- Part of: # -> use this if your PR is one of many related to one issue -->

<!-- 
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🛠 Need an AI review?  
Add a comment: `@coderabbitai review`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-->

## Description
<!-- Take the time to write a good summary. Why is it needed? What does it do? When fixing bugs try to avoid just writing “See original issue” – clarify what the problem was and how you’ve fixed it. -->

Turn `CartItem` into `protocol` that, represents an item with `id: UUID` and `CartItemType`:
- `.purchasableItem`
- `.coupon`

It resembles `POSItem` and allows to have a simpler aggregate model interface.

--

## Steps to reproduce
<!-- Step-by-step testing instructions. For new user flows, consider instead stating the goal of the workflow and see if your PR reviewer can accomplish the workflow without specific steps! -->

Prerequisites: Enable `enableCouponsInPointOfSale` feature flag

- Test adding and removing coupons and products
- Confirm 'Check out' button only appears when the product (not coupon) is added

## Testing information
<!-- This is your opportunity to break out individual scenarios that need testing (when necessary) and/or include a checklist for the reviewer to go through. Consider documenting the following from your own completed testing: devices used, alternate workflows, edge cases, affected areas, critical flows, areas not tested, and any remaining unknowns. Provide feedback on this new section of the PR template through Sept 30, 2024 to Apps Quality; additional context here: https://woomobilep2.wordpress.com/2024/05/06/woocommerce-mobile-quality-report-march-april/#comment-12036 -->

Tested on iPad Air 11 M3 iOS 18.4

---
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

## Reviewer (or Author, in the case of optional code reviews):

Please make sure these conditions are met before approving the PR, or request changes if the PR needs improvement:

- [ ] The PR is small and has a clear, single focus, or a valid explanation is provided in the description. If needed, please request to split it into smaller PRs.
- [ ] Ensure Adequate Unit Test Coverage: The changes are reasonably covered by unit tests or an explanation is provided in the PR description.
- [ ] Manual Testing: The author listed all the tests they ran, including smoke tests when needed (e.g., for refactorings). The reviewer confirmed that the PR works as expected on all devices (phone/tablet) and no regressions are added.



Diff:
diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift
index a81b18e60b3..0fe54152ace 100644
--- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift
+++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift
@@ -273,7 +273,7 @@ extension PointOfSaleOrderController {
 
 private extension POSCart {
     init(cart: Cart) {
-        let items = cart.items.map { POSCartItem(item: $0.item, quantity: Decimal($0.quantity)) }
+        let items = cart.purchasableItems.map { POSCartItem(item: $0.item, quantity: Decimal($0.quantity)) }
         let coupons = cart.coupons.map { POSCoupon(id: $0.id, code: $0.code, summary: $0.summary) }
         self.init(items: items, coupons: coupons)
     }
diff --git a/WooCommerce/Classes/POS/Models/Cart.swift b/WooCommerce/Classes/POS/Models/Cart.swift
index a7e6179f3a5..fe23c4b1388 100644
--- a/WooCommerce/Classes/POS/Models/Cart.swift
+++ b/WooCommerce/Classes/POS/Models/Cart.swift
@@ -3,27 +3,35 @@ import protocol Yosemite.POSOrderableItem
 import enum Yosemite.POSItem
 
 struct Cart {
-    var items: [CartItem] = []
-    var coupons: [CartCouponItem] = []
+    var purchasableItems: [Cart.PurchasableItem] = []
+    var coupons: [Cart.CouponItem] = []
 }
 
-struct CartItem {
-    let id: UUID
-    let item: POSOrderableItem
-    let title: String
-    let subtitle: String?
-    let quantity: Int
+protocol CartItem {
+    var id: UUID { get }
+    var type: CartItemType { get }
 }
 
-struct CartCouponItem {
-    let id: UUID
-    let code: String
-    let summary: String
+enum CartItemType: CaseIterable {
+    case purchasableItem
+    case coupon
+}
 
-    init(id: UUID, code: String, summary: String) {
-        self.id = id
-        self.code = code
-        self.summary = summary
+extension Cart {
+    struct PurchasableItem: CartItem {
+        let id: UUID
+        let item: POSOrderableItem
+        let title: String
+        let subtitle: String?
+        let quantity: Int
+        let type: CartItemType = .purchasableItem
+    }
+
+    struct CouponItem: CartItem {
+        let id: UUID
+        let code: String
+        let summary: String
+        let type: CartItemType = .coupon
     }
 }
 
@@ -33,34 +41,21 @@ extension Cart {
     mutating func add(_ posItem: POSItem) {
         switch posItem {
         case .simpleProduct(let simpleProduct):
-            let productItem = CartItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1)
-            items.insert(productItem, at: items.startIndex)
+            let productItem = Cart.PurchasableItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1)
+            purchasableItems.insert(productItem, at: purchasableItems.startIndex)
         case .variation(let variation):
-            let productItem = CartItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1)
-            items.insert(productItem, at: items.startIndex)
+            let productItem = Cart.PurchasableItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1)
+            purchasableItems.insert(productItem, at: purchasableItems.startIndex)
         case .variableParentProduct:
             return
         case .coupon(let coupon):
-            let couponItem = CartCouponItem(id: UUID(), code: coupon.code, summary: coupon.summary)
+            let couponItem = Cart.CouponItem(id: UUID(), code: coupon.code, summary: coupon.summary)
             coupons.insert(couponItem, at: coupons.startIndex)
         }
     }
 
-    mutating func remove(_ cartItem: CartItem) {
-        items.removeAll { $0.id == cartItem.id }
-    }
-
-    mutating func remove(_ cartCouponItem: CartCouponItem) {
-        coupons.removeAll { $0.id == cartCouponItem.id }
-    }
-
-    mutating func removeAll() {
-        items.removeAll()
-        coupons.removeAll()
-    }
-
     var isEmpty: Bool {
-        items.isEmpty && coupons.isEmpty
+        purchasableItems.isEmpty && coupons.isEmpty
     }
 
     var isNotEmpty: Bool {
diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
index 04e7783fb16..1a4f7d1feeb 100644
--- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
+++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
@@ -33,9 +33,7 @@ protocol PointOfSaleAggregateModelProtocol {
     var cart: Cart { get }
     func addToCart(_ item: POSItem)
     func remove(cartItem: CartItem)
-    func remove(cartCouponItem: CartCouponItem)
-    func removeAllItemsFromCart()
-    func removeAllCouponsFromCart()
+    func removeAllItemsFromCart(types: [CartItemType])
     func addMoreToCart()
     func startNewCart()
 
@@ -112,19 +110,23 @@ extension PointOfSaleAggregateModel {
     }
 
     func remove(cartItem: CartItem) {
-        cart.remove(cartItem)
-    }
-
-    func remove(cartCouponItem: CartCouponItem) {
-        cart.remove(cartCouponItem)
-    }
-
-    func removeAllItemsFromCart() {
-        cart.removeAll()
+        switch cartItem.type {
+        case .purchasableItem:
+            cart.purchasableItems.removeAll { $0.id == cartItem.id }
+        case .coupon:
+            cart.coupons.removeAll { $0.id == cartItem.id }
+        }
     }
 
-    func removeAllCouponsFromCart() {
-        cart.coupons.removeAll()
+    func removeAllItemsFromCart(types: [CartItemType] =  CartItemType.allCases) {
+        for type in types {
+            switch type {
+            case .purchasableItem:
+                cart.purchasableItems.removeAll()
+            case .coupon:
+                cart.coupons.removeAll()
+            }
+        }
     }
 
     func addMoreToCart() {
diff --git a/WooCommerce/Classes/POS/Presentation/CartView.swift b/WooCommerce/Classes/POS/Presentation/CartView.swift
index 405b6a95a92..a78c2ea495d 100644
--- a/WooCommerce/Classes/POS/Presentation/CartView.swift
+++ b/WooCommerce/Classes/POS/Presentation/CartView.swift
@@ -36,7 +36,7 @@ struct CartView: View {
                                   backButtonConfiguration: backButtonConfiguration,
                                   trailingContent: {
                     DynamicHStack(horizontalAlignment: .trailing, verticalAlignment: .center, spacing: Constants.cartHeaderElementSpacing) {
-                        if let itemsInCartLabel = viewHelper.itemsInCartLabel(for: posModel.cart.items.count) {
+                        if let itemsInCartLabel = viewHelper.itemsInCartLabel(for: posModel.cart.purchasableItems.count) {
                             Text(itemsInCartLabel)
                                 .font(Constants.itemsFont)
                                 .lineLimit(1)
@@ -66,7 +66,7 @@ struct CartView: View {
                                     couponsCartSectionView
                                 }
 
-                                ForEach(posModel.cart.items, id: \.id) { cartItem in
+                                ForEach(posModel.cart.purchasableItems, id: \.id) { cartItem in
                                     ItemRowView(cartItem: cartItem,
                                                 showImage: $shouldShowItemImages,
                                                 onItemRemoveTapped: posModel.orderStage == .building ? {
@@ -78,7 +78,7 @@ struct CartView: View {
                                 }
                             }
                             .padding(.bottom, Constants.cartLastItemBottomPadding)
-                            .animation(Constants.cartAnimation, value: posModel.cart.items.map(\.id))
+                            .animation(Constants.cartAnimation, value: posModel.cart.purchasableItems.map(\.id))
                             .animation(Constants.cartAnimation, value: posModel.cart.coupons.map(\.id))
                             .background(GeometryReader { geometry in
                                 Color.clear.preference(key: ScrollOffSetPreferenceKey.self,
@@ -114,7 +114,7 @@ struct CartView: View {
                             self.scrollViewHeight = height
                         }
                         .coordinateSpace(name: Constants.scrollViewCoordinateSpaceIdentifier)
-                        .onChange(of: posModel.cart.items.first?.id) { itemToScrollTo in
+                        .onChange(of: posModel.cart.purchasableItems.first?.id) { itemToScrollTo in
                             if posModel.orderStage == .building {
                                 withAnimation {
                                     proxy.scrollTo(itemToScrollTo)
@@ -128,7 +128,7 @@ struct CartView: View {
 
                 switch posModel.orderStage {
                 case .building:
-                    if posModel.cart.items.isEmpty {
+                    if posModel.cart.isEmpty {
                         EmptyView()
                     } else {
                         checkoutButton
@@ -328,7 +328,7 @@ private extension CartView {
                                                                         couponItem: couponItem),
                               showImage: $shouldShowItemImages,
                               onItemRemoveTapped: posModel.orderStage == .building ? {
-                    posModel.remove(cartCouponItem: couponItem)
+                    posModel.remove(cartItem: couponItem)
                 } : nil)
                 .id(couponItem.id)
                 .transition(.opacity)
@@ -340,7 +340,7 @@ private extension CartView {
 @available(iOS 17.0, *)
 private extension CartView {
     func trackCheckoutTapped() {
-        let itemsInCart = posModel.cart.items.count
+        let itemsInCart = posModel.cart.purchasableItems.count
         ServiceLocator.analytics.track(event: .PointOfSale.checkoutTapped(itemsInCart))
     }
 }
diff --git a/WooCommerce/Classes/POS/Presentation/CouponRowView.swift b/WooCommerce/Classes/POS/Presentation/CouponRowView.swift
index ad590b75a4e..033a5c0f851 100644
--- a/WooCommerce/Classes/POS/Presentation/CouponRowView.swift
+++ b/WooCommerce/Classes/POS/Presentation/CouponRowView.swift
@@ -1,14 +1,14 @@
 import SwiftUI
 
 struct CouponRowView: View {
-    private let couponItem: CartCouponItem
+    private let couponItem: Cart.CouponItem
     private let couponRowState: CouponRowState?
     private let onItemRemoveTapped: (() -> Void)?
 
     @ScaledMetric private var scale: CGFloat = 1.0
     @Binding private var showImage: Bool
 
-    init(couponItem: CartCouponItem,
+    init(couponItem: Cart.CouponItem,
          couponRowState: CouponRowState? = nil,
          showImage: Binding<Bool> = .constant(true),
          onItemRemoveTapped: (() -> Void)? = nil
@@ -109,6 +109,6 @@ private extension CouponRowView {
 #if DEBUG
 @available(iOS 17.0, *)
 #Preview(traits: .sizeThatFitsLayout) {
-    CouponRowView(couponItem: CartCouponItem(id: UUID(), code: "10-Discount", summary: "$10 Off · All products"), couponRowState: .idle) {}
+    CouponRowView(couponItem: Cart.CouponItem(id: UUID(), code: "10-Discount", summary: "$10 Off · All products"), couponRowState: .idle) {}
 }
 #endif
diff --git a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift
index a50cf799eb5..af8f37a794a 100644
--- a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift
+++ b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift
@@ -1,7 +1,7 @@
 import SwiftUI
 
 struct ItemRowView: View {
-    private let cartItem: CartItem
+    private let cartItem: Cart.PurchasableItem
     private let onItemRemoveTapped: (() -> Void)?
 
     @ScaledMetric private var scale: CGFloat = 1.0
@@ -11,7 +11,7 @@ struct ItemRowView: View {
         min(Constants.productCardSize * scale, Constants.maximumProductCardSize)
     }
 
-    init(cartItem: CartItem, showImage: Binding<Bool> = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) {
+    init(cartItem: Cart.PurchasableItem, showImage: Binding<Bool> = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) {
         self.cartItem = cartItem
         self._showProductImage = showImage
         self.onItemRemoveTapped = onItemRemoveTapped
@@ -81,21 +81,21 @@ private extension ItemRowView {
 #if DEBUG
 @available(iOS 17.0, *)
 #Preview(traits: .sizeThatFitsLayout) {
-    ItemRowView(cartItem: CartItem(id: UUID(),
-                                   item: PointOfSalePreviewItemService().providePointOfSaleItem(),
-                                   title: "Item Title",
-                                   subtitle: "Item Subtitle",
-                                   quantity: 2),
+    ItemRowView(cartItem: Cart.PurchasableItem(id: UUID(),
+                                               item: PointOfSalePreviewItemService().providePointOfSaleItem(),
+                                               title: "Item Title",
+                                               subtitle: "Item Subtitle",
+                                               quantity: 2),
                 onItemRemoveTapped: { })
 }
 
 @available(iOS 17.0, *)
 #Preview(traits: .sizeThatFitsLayout) {
-    ItemRowView(cartItem: CartItem(id: UUID(),
-                                   item: PointOfSalePreviewItemService().providePointOfSaleItem(),
-                                   title: "Item Title",
-                                   subtitle: nil,
-                                   quantity: 2),
+    ItemRowView(cartItem: Cart.PurchasableItem(id: UUID(),
+                                               item: PointOfSalePreviewItemService().providePointOfSaleItem(),
+                                               title: "Item Title",
+                                               subtitle: nil,
+                                               quantity: 2),
                 onItemRemoveTapped: { })
 }
 #endif
diff --git a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift
index 0d3d4a75b63..8a1c36f9e30 100644
--- a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift	
+++ b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift	
@@ -34,7 +34,7 @@ struct PointOfSaleOrderSyncCouponsErrorMessageView: View {
                         .buttonStyle(POSFilledButtonStyle(size: .normal))
 
                         Button(retryActionTitle, action: {
-                            posModel.removeAllCouponsFromCart()
+                            posModel.removeAllItemsFromCart(types: [.coupon])
                             retryHandler()
                         })
                         .buttonStyle(POSOutlinedButtonStyle(size: .normal))
diff --git a/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift b/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift
index 5b848728898..e94eceb8d6e 100644
--- a/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift
+++ b/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift
@@ -56,7 +56,7 @@ extension CartViewHelper {
     func couponRowState(
         orderStage: PointOfSaleOrderStage,
         orderState: PointOfSaleOrderState,
-        couponItem: CartCouponItem
+        couponItem: Cart.CouponItem
     ) -> CouponRowState {
         guard orderStage == .finalizing else {
             return .idle
diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift
index 1059e539ea4..a2a7e7d2bfc 100644
--- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift
+++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift
@@ -38,12 +38,12 @@ struct PointOfSaleOrderControllerTests {
         let fakeOrder = Order.fake().copy(items: [orderItem])
         let cartItem = makeItem(orderItemsToMatch: [orderItem])
         mockOrderService.orderToReturn = fakeOrder
-        await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {})
 
         mockOrderService.syncOrderWasCalled = false
 
         // When
-        await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {})
 
         // Then
         #expect(mockOrderService.syncOrderWasCalled == false)
@@ -56,14 +56,14 @@ struct PointOfSaleOrderControllerTests {
                                              receiptService: mockReceiptService)
         mockOrderService.simulateSyncing = true
         Task {
-            await sut.syncOrder(for: .init(items: [makeItem(quantity: 1)]), retryHandler: {})
+            await sut.syncOrder(for: .init(purchasableItems: [makeItem(quantity: 1)]), retryHandler: {})
         }
         try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC)))
         mockOrderService.syncOrderWasCalled = false
 
         // When
-        await sut.syncOrder(for: .init(items: [makeItem(quantity: 2),
-                                               makeItem(quantity: 5)]),
+        await sut.syncOrder(for: .init(purchasableItems: [makeItem(quantity: 2),
+                                                          makeItem(quantity: 5)]),
                             retryHandler: {})
 
         // Then
@@ -83,7 +83,7 @@ struct PointOfSaleOrderControllerTests {
                                              currencySettings: currencySettings)
 
         // When
-        await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {})
 
         // Then
         #expect(mockOrderService.spySyncOrderCurrency == .AUD)
@@ -102,8 +102,8 @@ struct PointOfSaleOrderControllerTests {
         let futureOrderItem = OrderItem.fake().copy(quantity: 5)
 
         // When
-        await sut.syncOrder(for: .init(items: [cartItem,
-                                               makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])]),
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem,
+                                                          makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])]),
                             retryHandler: {})
 
         // Then
@@ -134,7 +134,7 @@ struct PointOfSaleOrderControllerTests {
             observeOrderState()
 
             // When
-            await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {})
+            await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {})
         }
 
         await orderStateAppendTask?.value
@@ -172,7 +172,7 @@ struct PointOfSaleOrderControllerTests {
             observeOrderState()
 
             // When
-            await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {})
+            await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {})
         }
 
         await orderStateAppendTask?.value
@@ -210,7 +210,7 @@ struct PointOfSaleOrderControllerTests {
         mockOrderService.orderToReturn = order
 
         // We need an existing order before we can update its email, and send a receipt:
-        await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: { })
+        await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { })
 
         // When
         try await sut.sendReceipt(recipientEmail: recipientEmail)
@@ -250,7 +250,7 @@ struct PointOfSaleOrderControllerTests {
         let orderItem = OrderItem.fake()
         let fakeOrder = Order.fake().copy(items: [orderItem])
         mockOrderService.orderToReturn = fakeOrder
-        await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {})
 
         // When
         let completionResult: Bool = await withCheckedContinuation { continuation in
@@ -288,7 +288,7 @@ struct PointOfSaleOrderControllerTests {
         mockOrderService.orderToReturn = fakeOrder
 
         // When
-        let result = await sut.syncOrder(for: .init(items: [fakeCartItem]), retryHandler: { })
+        let result = await sut.syncOrder(for: .init(purchasableItems: [fakeCartItem]), retryHandler: { })
 
         // Then
         if case .success(let state) = result {
@@ -308,10 +308,10 @@ struct PointOfSaleOrderControllerTests {
 
         // When
         // 1. Initial order
-        _ = await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {})
+        _ = await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {})
 
         // 2. Sync existing order
-        let result = await sut.syncOrder(for: .init(items: [makeItem(), makeItem()]), retryHandler: {})
+        let result = await sut.syncOrder(for: .init(purchasableItems: [makeItem(), makeItem()]), retryHandler: {})
 
         // Then
         if case .success(let state) = result {
@@ -333,10 +333,10 @@ struct PointOfSaleOrderControllerTests {
 
         // When
         // 1. Initial order
-        _ = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
+        _ = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {})
 
         // 2. Syncing existing order with same cart should not update order
-        let result = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
+        let result = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {})
 
         // Then
         if case .success(let state) = result {
@@ -354,7 +354,7 @@ struct PointOfSaleOrderControllerTests {
 
         // When
         mockOrderService.orderToReturn = nil
-        let result = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
+        let result = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {})
 
         // Then
         if case .failure(let error) = result {
@@ -377,12 +377,12 @@ struct PointOfSaleOrderControllerTests {
         mockOrderService.orderToReturn = fakeOrder
 
         // Initial sync to set up the order
-        await sut.syncOrder(for: .init(items: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {})
 
         mockOrderService.syncOrderWasCalled = false
 
         // When - sync with same items and coupons
-        await sut.syncOrder(for: .init(items: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {})
 
         // Then
         #expect(mockOrderService.syncOrderWasCalled == false)
@@ -401,12 +401,12 @@ struct PointOfSaleOrderControllerTests {
         mockOrderService.orderToReturn = fakeOrder
 
         // Initial sync
-        await sut.syncOrder(for: .init(items: [cartItem], coupons: [.init(id: UUID(), code: initialCouponCode, summary: "")]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: initialCouponCode, summary: "")]), retryHandler: {})
 
         mockOrderService.syncOrderWasCalled = false
 
         // When - sync with same items but different coupon
-        await sut.syncOrder(for: .init(items: [cartItem], coupons: [.init(id: UUID(), code: "DIFFERENT20", summary: "")]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: "DIFFERENT20", summary: "")]), retryHandler: {})
 
         // Then
         #expect(mockOrderService.syncOrderWasCalled == true)
@@ -425,12 +425,12 @@ struct PointOfSaleOrderControllerTests {
         mockOrderService.orderToReturn = fakeOrder
 
         // Initial sync with coupon
-        await sut.syncOrder(for: .init(items: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {})
 
         mockOrderService.syncOrderWasCalled = false
 
         // When - sync with same items but no coupons
-        await sut.syncOrder(for: .init(items: [cartItem], coupons: []), retryHandler: {})
+        await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: []), retryHandler: {})
 
         // Then
         #expect(mockOrderService.syncOrderWasCalled == true)
@@ -440,7 +440,7 @@ struct PointOfSaleOrderControllerTests {
     @Test func syncOrder_when_orderService_fails_with_couponsError_then_sets_invalidCoupon_error() async throws {
         // Given
         let sut = PointOfSaleOrderController(orderService: mockOrderService,
-                                           receiptService: mockReceiptService)
+                                             receiptService: mockReceiptService)
         let errorMessage = "Invalid coupon code"
         mockOrderService.errorToReturn = DotcomError.unknown(code: "woocommerce_rest_invalid_coupon", message: errorMessage)
 
@@ -461,9 +461,9 @@ struct PointOfSaleOrderControllerTests {
             observeOrderState()
 
             // When
-            await sut.syncOrder(for: .init(items: [makeItem()],
-                                         coupons: [.init(id: UUID(), code: "INVALID", summary: "")]),
-                              retryHandler: {})
+            await sut.syncOrder(for: .init(purchasableItems: [makeItem()],
+                                           coupons: [.init(id: UUID(), code: "INVALID", summary: "")]),
+                                retryHandler: {})
         }
 
         await orderStateAppendTask?.value
@@ -480,7 +480,7 @@ struct PointOfSaleOrderControllerTests {
     @Test func syncOrder_when_orderService_fails_with_networkError_containing_couponsError_then_sets_invalidCoupon_error() async throws {
         // Given
         let sut = PointOfSaleOrderController(orderService: mockOrderService,
-                                           receiptService: mockReceiptService)
+                                             receiptService: mockReceiptService)
         let errorMessage = "Coupon INVALID does not exist"
         let errorJSON = """
         {
@@ -508,9 +508,9 @@ struct PointOfSaleOrderControllerTests {
             observeOrderState()
 
             // When
-            await sut.syncOrder(for: .init(items: [makeItem()],
-                                         coupons: [.init(id: UUID(), code: "INVALID", summary: "")]),
-                              retryHandler: {})
+            await sut.syncOrder(for: .init(purchasableItems: [makeItem()],
+                                           coupons: [.init(id: UUID(), code: "INVALID", summary: "")]),
+                                retryHandler: {})
         }
 
         await orderStateAppendTask?.value
@@ -536,7 +536,7 @@ struct PointOfSaleOrderControllerTests {
         mockOrderService.orderToReturn = fakeOrder
 
         // Initial sync succeeds
-        let initialResult = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
+        let initialResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {})
         switch initialResult {
         case .success(.newOrder):
             break
@@ -546,7 +546,7 @@ struct PointOfSaleOrderControllerTests {
 
         // Then simulate a failure
         mockOrderService.errorToReturn = SyncOrderStateError.syncFailure
-        let failureResult = await sut.syncOrder(for: .init(items: [cartItem, cartItem]), retryHandler: {})
+        let failureResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem, cartItem]), retryHandler: {})
         switch failureResult {
         case .failure(SyncOrderStateError.syncFailure):
             break
@@ -557,7 +557,7 @@ struct PointOfSaleOrderControllerTests {
         // When - try syncing with the same cart again
         mockOrderService.errorToReturn = nil
         mockOrderService.orderToReturn = fakeOrder // Restore mock to return success
-        let subsequentResult = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {})
+        let subsequentResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {})
 
         // Then - should be treated as new order since previous order was cleared
         switch subsequentResult {
@@ -590,7 +590,7 @@ struct PointOfSaleOrderControllerTests {
             orderService.orderToReturn = fakeOrder
 
             // When
-            await sut.syncOrder(for: .init(items: [fakeCartItem]), retryHandler: { })
+            await sut.syncOrder(for: .init(purchasableItems: [fakeCartItem]), retryHandler: { })
 
             // Then
             #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_success" }) != nil)
@@ -605,7 +605,7 @@ struct PointOfSaleOrderControllerTests {
             orderService.orderToReturn = nil
 
             // When
-            await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {})
+            await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {})
 
             // Then
             #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_failed" }) != nil)
@@ -633,7 +633,7 @@ struct PointOfSaleOrderControllerTests {
             let orderItem = OrderItem.fake()
             let fakeOrder = Order.fake().copy(items: [orderItem])
             mockOrderService.orderToReturn = fakeOrder
-            await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {})
+            await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {})
 
             // When
             await withCheckedContinuation { continuation in
@@ -660,12 +660,12 @@ struct PointOfSaleOrderControllerTests {
 private func makeItem(name: String = "",
                       formattedPrice: String = "",
                       quantity: Int = 1,
-                      orderItemsToMatch: [OrderItem] = []) -> CartItem {
-    return CartItem(id: UUID(),
-                    item: MockPOSOrderableItem(name: name,
-                                               formattedPrice: formattedPrice,
-                                               orderItemsToMatch: orderItemsToMatch),
-                    title: name,
-                    subtitle: nil,
-                    quantity: quantity)
+                      orderItemsToMatch: [OrderItem] = []) -> Cart.PurchasableItem {
+    return .init(id: UUID(),
+                 item: MockPOSOrderableItem(name: name,
+                                            formattedPrice: formattedPrice,
+                                            orderItemsToMatch: orderItemsToMatch),
+                 title: name,
+                 subtitle: nil,
+                 quantity: quantity)
 }
diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift
index 07158763684..8e11285479c 100644
--- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift
+++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift
@@ -62,14 +62,12 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol {
 
     func remove(cartItem: CartItem) { }
 
-    func remove(cartCouponItem: CartCouponItem) { }
-
     var removeAllItemsFromCartCalled = false
     func removeAllItemsFromCart() {
         removeAllItemsFromCartCalled = true
     }
 
-    func removeAllCouponsFromCart() { }
+    func removeAllItemsFromCart(types: [CartItemType]) { }
 
     func checkOut() async { }
 
diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift
index b6fb9ff29f8..aac51fd2f4e 100644
--- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift
+++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift
@@ -16,7 +16,7 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
     var orderStateToReturn: PointOfSaleInternalOrderState?
 
     var syncOrderWasCalled: Bool = false
-    var spyCartProducts: [CartItem]?
+    var spyCartProducts: [Cart.PurchasableItem]?
     var spyRetryHandler: (() async -> Void)?
     var syncOrderResultToReturn: Result<SyncOrderState, Error> = .success(.newOrder)
 
@@ -24,7 +24,7 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
     func syncOrder(for cart: Cart,
                    retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
         syncOrderWasCalled = true
-        spyCartProducts = cart.items
+        spyCartProducts = cart.purchasableItems
         spyRetryHandler = retryHandler
 
         guard let orderStateToReturn else {
diff --git a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift
index c39c65667e0..08930cc2e03 100644
--- a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift
+++ b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift
@@ -37,7 +37,7 @@ struct PointOfSaleAggregateModelTests {
                                                 analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()),
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
             await sut.checkOut()
             try #require(sut.orderStage == .finalizing)
             try #require(!sut.cart.isEmpty)
@@ -61,7 +61,7 @@ struct PointOfSaleAggregateModelTests {
                                                 analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()),
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
 
             // When
             await sut.checkOut()
@@ -81,7 +81,7 @@ struct PointOfSaleAggregateModelTests {
                                                 analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()),
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
             await sut.checkOut()
             try #require(sut.orderStage == .finalizing)
 
@@ -115,7 +115,7 @@ struct PointOfSaleAggregateModelTests {
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
             try #require(sut.cart.isEmpty)
-            let item = makeItem()
+            let item = makePurchasableItem()
 
             // When
             sut.addToCart(item)
@@ -135,13 +135,13 @@ struct PointOfSaleAggregateModelTests {
                                                 analytics: analytics,
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
-            let items = [makeItem(), makeItem(), makeItem()]
+            let items = [makePurchasableItem(), makePurchasableItem(), makePurchasableItem()]
 
             // When
             items.forEach(sut.addToCart(_:))
 
             // Then
-            #expect(sut.cart.items.map(\.item.id) == items.reversed().map(\.id))
+            #expect(sut.cart.purchasableItems.map(\.item.id) == items.reversed().map(\.id))
         }
 
         @available(iOS 17.0, *)
@@ -155,20 +155,20 @@ struct PointOfSaleAggregateModelTests {
                                                 analytics: analytics,
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
-            let item = makeItem(name: "Item 1")
-            let anotherItem = makeItem(name: "Item 2")
+            let item = makePurchasableItem(name: "Item 1")
+            let anotherItem = makePurchasableItem(name: "Item 2")
 
             sut.addToCart(item)
             sut.addToCart(anotherItem)
-            try #require(sut.cart.items.count == 2)
+            try #require(sut.cart.purchasableItems.count == 2)
 
             // When
-            let firstItem = try #require(sut.cart.items.first)
+            let firstItem = try #require(sut.cart.purchasableItems.first)
             sut.remove(cartItem: firstItem)
 
             // Then
-            #expect(sut.cart.items.count == 1)
-            #expect(sut.cart.items.first?.title == "Item 1")
+            #expect(sut.cart.purchasableItems.count == 1)
+            #expect(sut.cart.purchasableItems.first?.title == "Item 1")
         }
 
         @available(iOS 17.0, *)
@@ -182,12 +182,12 @@ struct PointOfSaleAggregateModelTests {
                                                 analytics: analytics,
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
-            let item = makeItem(name: "Item 1")
-            let anotherItem = makeItem(name: "Item 2")
+            let item = makePurchasableItem(name: "Item 1")
+            let anotherItem = makePurchasableItem(name: "Item 2")
 
             sut.addToCart(item)
             sut.addToCart(anotherItem)
-            try #require(sut.cart.items.count == 2)
+            try #require(sut.cart.purchasableItems.count == 2)
 
             // When
             sut.removeAllItemsFromCart()
@@ -196,6 +196,34 @@ struct PointOfSaleAggregateModelTests {
             #expect(sut.cart.isEmpty)
         }
 
+        @available(iOS 17.0, *)
+        @Test func removeAllItemsFromCartOfCouponType_removes_coupons() async throws {
+            // Given
+            let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
+                                                purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
+                                                couponsController: MockPointOfSaleCouponsController(),
+                                                cardPresentPaymentService: MockCardPresentPaymentService(),
+                                                orderController: MockPointOfSaleOrderController(),
+                                                analytics: analytics,
+                                                collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
+                                                searchHistoryService: MockPOSSearchHistoryService())
+            let item = makePurchasableItem(name: "Item 1")
+            let anotherItem = makePurchasableItem(name: "Item 2")
+            let couponItem = makeCouponItem(code: "VALID")
+            sut.addToCart(item)
+            sut.addToCart(anotherItem)
+            sut.addToCart(couponItem)
+            try #require(sut.cart.purchasableItems.count == 2)
+            try #require(sut.cart.coupons.count == 1)
+
+            // When
+            sut.removeAllItemsFromCart(types: [.coupon])
+
+            // Then
+            #expect(sut.cart.purchasableItems.count == 2)
+            #expect(sut.cart.coupons.count == 0)
+        }
+
         @available(iOS 17.0, *)
         @Test(.disabled(
             """
@@ -213,7 +241,7 @@ struct PointOfSaleAggregateModelTests {
                                                 analytics: analytics,
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                                                 searchHistoryService: MockPOSSearchHistoryService())
-            let item = makeItem()
+            let item = makePurchasableItem()
 
             // When
             sut.addToCart(item)
@@ -246,7 +274,7 @@ struct PointOfSaleAggregateModelTests {
                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                 searchHistoryService: MockPOSSearchHistoryService())
 
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
 
             // When
             sut.startNewCart()
@@ -269,9 +297,9 @@ struct PointOfSaleAggregateModelTests {
                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                 searchHistoryService: MockPOSSearchHistoryService())
 
-            sut.addToCart(makeItem())
-            sut.addToCart(makeItem())
-            let item = try #require(sut.cart.items.first)
+            sut.addToCart(makePurchasableItem())
+            sut.addToCart(makePurchasableItem())
+            let item = try #require(sut.cart.purchasableItems.first)
 
             // When
             await sut.checkOut()
@@ -295,7 +323,7 @@ struct PointOfSaleAggregateModelTests {
                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                 searchHistoryService: MockPOSSearchHistoryService())
 
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
             sut.removeAllItemsFromCart()
 
             // When
@@ -320,7 +348,7 @@ struct PointOfSaleAggregateModelTests {
                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                 searchHistoryService: MockPOSSearchHistoryService())
 
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
             cardPresentPaymentService.connectedReader = .init(name: "Test reader", batteryLevel: 0.7)
             orderController.orderStateToReturn = makeLoadedOrderState(orderTotal: "$1.00", orderTotalDecimal: 1)
 
@@ -393,7 +421,7 @@ struct PointOfSaleAggregateModelTests {
                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                 searchHistoryService: MockPOSSearchHistoryService())
 
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
 
             // When
             sut.pointOfSaleClosed()
@@ -905,7 +933,7 @@ struct PointOfSaleAggregateModelTests {
                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                 searchHistoryService: MockPOSSearchHistoryService())
 
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
 
             let onboardingViewModel = CardPresentPaymentsOnboardingViewModel(
                 fixedState: .noConnectionError,
@@ -937,7 +965,7 @@ struct PointOfSaleAggregateModelTests {
                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
                 searchHistoryService: MockPOSSearchHistoryService())
 
-            sut.addToCart(makeItem())
+            sut.addToCart(makePurchasableItem())
 
             // When
             sut.trackCardPaymentsOnboardingShown()
@@ -1049,7 +1077,7 @@ struct PointOfSaleAggregateModelTests {
     }
 }
 
-private func makeItem(name: String = "") -> POSItem {
+private func makePurchasableItem(name: String = "") -> POSItem {
     return .simpleProduct(POSSimpleProduct(
         id: UUID(),
         name: name,
@@ -1058,6 +1086,10 @@ private func makeItem(name: String = "") -> POSItem {
         price: ""))
 }
 
+private func makeCouponItem(code: String = "") -> POSItem {
+    return .coupon(.init(id: UUID(), code: code))
+}
+
 private func makeLoadedOrderState(cartTotal: String = "",
                                   orderTotal: String = "",
                                   taxTotal: String = "",
diff --git a/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift b/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift
index 05a8da38001..777a2946f4d 100644
--- a/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift
+++ b/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift
@@ -79,16 +79,16 @@ struct CartViewHelperTests {
     }
 
     @Test func shouldShowClearCartButton_items_in_cart_and_building_true() async throws {
-        #expect(sut.shouldShowClearCartButton(cart: .init(items: [makeItem()]), orderStage: .building) == true)
+        #expect(sut.shouldShowClearCartButton(cart: .init(purchasableItems: [makeItem()]), orderStage: .building) == true)
     }
 
     @Test func shouldShowClearCartButton_items_in_cart_and_finalizing_false() async throws {
-        #expect(sut.shouldShowClearCartButton(cart: .init(items: [makeItem()]), orderStage: .finalizing) == false)
+        #expect(sut.shouldShowClearCartButton(cart: .init(purchasableItems: [makeItem()]), orderStage: .finalizing) == false)
     }
 
     @Test func couponRowState_building_stage_returns_idle() async throws {
         // Given
-        let coupon = CartCouponItem(id: UUID(), code: "TEST10", summary: "")
+        let coupon = Cart.CouponItem(id: UUID(), code: "TEST10", summary: "")
         let orderState = PointOfSaleOrderState.loaded(PointOfSaleOrderTotals(
             cartTotal: "$10.00",
             orderTotal: "$12.00",
@@ -103,7 +103,7 @@ struct CartViewHelperTests {
 
     @Test func couponRowState_finalizing_and_syncing_returns_validating() async throws {
         // Given
-        let coupon = CartCouponItem(id: UUID(), code: "TEST10", summary: "")
+        let coupon = Cart.CouponItem(id: UUID(), code: "TEST10", summary: "")
 
         // When, Then
         #expect(sut.couponRowState(orderStage: .finalizing,
@@ -114,7 +114,7 @@ struct CartViewHelperTests {
     @Test func couponRowState_finalizing_and_loaded_with_matching_coupon_returns_valid() async throws {
         // Given
         let couponCode = "TEST10"
-        let coupon = CartCouponItem(id: UUID(), code: couponCode, summary: "")
+        let coupon = Cart.CouponItem(id: UUID(), code: couponCode, summary: "")
         let couponTotal = PointOfSaleCouponTotal(code: couponCode, total: "10.00")
         let orderTotals = PointOfSaleOrderTotals(
             cartTotal: "$10.00",
@@ -131,7 +131,7 @@ struct CartViewHelperTests {
 
     @Test func couponRowState_finalizing_and_loaded_with_no_matching_coupon_returns_idle() async throws {
         // Given
-        let coupon = CartCouponItem(id: UUID(), code: "TEST10", summary: "")
+        let coupon = Cart.CouponItem(id: UUID(), code: "TEST10", summary: "")
         let orderTotals = PointOfSaleOrderTotals(
             cartTotal: "$10.00",
             orderTotal: "$12.00",
@@ -147,7 +147,7 @@ struct CartViewHelperTests {
 
     @Test func couponRowState_finalizing_and_invalid_coupon_error_returns_invalid() async throws {
         // Given
-        let coupon = CartCouponItem(id: UUID(), code: "TEST10", summary: "")
+        let coupon = Cart.CouponItem(id: UUID(), code: "TEST10", summary: "")
 
         // When, Then
         #expect(sut.couponRowState(orderStage: .finalizing,
@@ -157,7 +157,7 @@ struct CartViewHelperTests {
 
     @Test func couponRowState_finalizing_and_other_error_returns_idle() async throws {
         // Given
-        let coupon = CartCouponItem(id: UUID(), code: "TEST10", summary: "")
+        let coupon = Cart.CouponItem(id: UUID(), code: "TEST10", summary: "")
 
         // When, Then
         #expect(sut.couponRowState(orderStage: .finalizing,
@@ -166,10 +166,10 @@ struct CartViewHelperTests {
     }
 }
 
-private func makeItem() -> CartItem {
-    CartItem(id: UUID(),
-             item: MockPOSOrderableItem(name: "Item", formattedPrice: "$1.00"),
-             title: "Item",
-             subtitle: nil,
-             quantity: 1)
+private func makeItem() -> Cart.PurchasableItem {
+    .init(id: UUID(),
+          item: MockPOSOrderableItem(name: "Item", formattedPrice: "$1.00"),
+          title: "Item",
+          subtitle: nil,
+          quantity: 1)
 }


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 12 out of 12 changed files in this pull request and generated no comments.


<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**WooCommerce/Classes/POS/Models/Cart.swift:57**
* Ensure that the removal logic using removeAll in the switch statement properly handles cases where multiple items of the same kind exist. Verify that this behavior is intended and adequately tested.
```
switch cartItem { case .purchasableItem(let item): ... case .coupon(let coupon): ... }
```
**WooCommerce/Classes/POS/Models/Cart.swift:72**
* Consider adding tests to verify that the isEmpty property correctly reflects the cart's state when items are added and removed from both purchasableItems and coupons arrays.
```
var isEmpty: Bool { purchasableItems.isEmpty && coupons.isEmpty }
```
**WooCommerce/Classes/POS/Models/Cart.swift:6**
* [nitpick] The name 'purchasableItems' clearly distinguishes these from coupons; however, consider if a simplified name like 'items' might improve readability now that the cart no longer mixes different types.
```
var purchasableItems: [CartItem.PurchasableItem] = []
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T19:13:07Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 15 out of 15 changed files in this pull request and generated no comments.


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**WooCommerce/Classes/POS/Models/Cart.swift:44**
* Consider verifying whether invoking onAddToCart before inserting the new item correctly captures the intended cart state for customer interaction tracking. If the goal is to track the updated cart, moving this call after the item insertion might be more appropriate.
```
onAddToCart(self)
```
**WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift:124**
* [nitpick] Consider renaming the 'cart' parameter (e.g., to 'previousCart') or adding a clarifying comment, so it’s clear that the state passed represents the cart prior to the current addition.
```
func trackCustomerInteractionStarted(cart: Cart) {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:18:53Z
user: login: joshheald
body: It works well, and I don't want to block anything or cause a difficult merge... so just ping me and I'll swap this to `approved` – but I've got a few concerns about the switch to expose the cart.

I'm not totally against it, but not totally for it either. It does have some similarities to exposing the item controllers, but seems a little different to me.

Perhaps the cart should actually have its own controller to expose, observable and all? Or we could go with what you have here, or revert the changes to cart access.

Also, I wasn't sure about the merge as mentioned in slack, so check that I got the buttons the right way round in `OrderSyncCouponsErrorMessageView`
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:05:55Z
user: login: staskus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:15:06Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift:112**
* The switch on cartItem.type in remove(cartItem:) does not include a default case. Consider adding a default branch to handle potential new CartItem types in the future.
```
func remove(cartItem: CartItem) {
```
**WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift:121**
* [nitpick] Consider renaming the parameter 'types' to 'itemTypes' for improved clarity on what is being filtered.
```
func removeAllItemsFromCart(types: [CartItemType] =  CartItemType.allCases) {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:58:48Z
user: login: joshheald
body: I've only read the code, not retested it, but it looks good. Thanks for the changes.

One small thing inline,
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T17:29:52Z

Review comments:
diff_hunk: @@ -30,12 +30,7 @@ protocol PointOfSaleAggregateModelProtocol {
     var purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { get }
     var couponsController: PointOfSaleCouponsControllerProtocol { get }
 
-    var cart: Cart { get }
-    func addToCart(_ item: POSItem)
-    func remove(cartItem: CartItem)
-    func remove(cartCouponItem: CartCouponItem)
-    func removeAllItemsFromCart()
-    func removeAllCouponsFromCart()
path: WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
user: login: joshheald
body: I'm not sure about exposing the setter here. It feels a little odd:

1. Cart isn't itself `Observable`, so I'm not _sure_ if direct use of `posModel.cart.purchasableItems` from the view would trigger updates. I'm not sure if we need to use it in that way, but if we do, we could just make it `Observable`
2. At the callsite, it feels a little strange to be calling functions on the cart: `posModel.cart.add(item)` is the kind of thing I mean. I don't tend to get too hung up on SOLID, but the "rule of thumb of Demeter" that I try to stick with is not calling functions on things you don't declare a direct dependency on.
3. When you do this from the view, it requires `posModel` to be declared as a `var` because it's a mutating function. Most of the time it is anyway, but in the ActionHandler it wasn't so it's a slightly clunky-looking change.

Not sure, I can see the appeal of removing some of the duplication... but I think you'd get similar benefits by changing these to:


```
    var cart: Cart { get }
    func addToCart(_ item: POSItem)
    func remove(cartItem: CartItem)
    func removeAllItemsFromCart(type: CartItemType)
```

That is yet another type enum though...
created_at: 2025-04-16T16:53:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,64 +3,81 @@ import protocol Yosemite.POSOrderableItem
 import enum Yosemite.POSItem
 
 struct Cart {
-    var items: [CartItem] = []
-    var coupons: [CartCouponItem] = []
-}
+    private(set) var purchasableItems: [CartItem.PurchasableItem] = []
+    private(set) var coupons: [CartItem.CouponItem] = []
 
-struct CartItem {
-    let id: UUID
-    let item: POSOrderableItem
-    let title: String
-    let subtitle: String?
-    let quantity: Int
+    var onAddToCart: ((Cart) -> Void) = { _ in }
path: WooCommerce/Classes/POS/Models/Cart.swift
user: login: joshheald
body: There's a bit of a complexity cost to this when it comes to reading the code. Do you think it's worth it for starting the customer interaction tracking? If not, this is another reason not to expose the cart setters.
created_at: 2025-04-16T16:56:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,7 +32,7 @@ extension POSItemActionHandler {
 /// Standard handler for handling item taps without any special context
 @available(iOS 17.0, *)
 final class StandardPOSItemActionHandler: POSItemActionHandler {
-    private let posModel: PointOfSaleAggregateModelProtocol
+    private var posModel: PointOfSaleAggregateModelProtocol
path: WooCommerce/Classes/POS/Presentation/Item Selector/POSItemActionHandler.swift
user: login: joshheald
body: This is what I referenced in point 3 of my previous comment
created_at: 2025-04-16T16:58:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,7 +33,7 @@ struct PointOfSaleAggregateModelTests {
                                                 orderController: MockPointOfSaleOrderController(),
                                                 analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()),
                                                 collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker())
-            sut.addToCart(makeItem())
+            sut.cart.add(makeItem())
path: WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift
user: login: joshheald
body: Does the cart change also mean that we're forced to use a real cart here, and couldn't just swap to a mock so easily for future test improvements? Probably not a huge deal but I wonder what implication it has for testing the cart now that it's gaining logic.
created_at: 2025-04-16T17:01:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,12 +30,7 @@ protocol PointOfSaleAggregateModelProtocol {
     var purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { get }
     var couponsController: PointOfSaleCouponsControllerProtocol { get }
 
-    var cart: Cart { get }
-    func addToCart(_ item: POSItem)
-    func remove(cartItem: CartItem)
-    func remove(cartCouponItem: CartCouponItem)
-    func removeAllItemsFromCart()
-    func removeAllCouponsFromCart()
path: WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
user: login: staskus
body: I've done this to simplify the aggregate model interface, similar to what we did with the controller.

However, I agree that this change is slightly clunky, especially around `onAddToCart` handler.

> Perhaps the cart should actually have its own controller to expose, observable and all? Or we could go with what you have here, or revert the changes to cart access.

I think at the moment we can come back. This is not a critical area so I don't want to prioritize a refactoring around the cart 👍 
created_at: 2025-04-17T08:15:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,19 +110,23 @@ extension PointOfSaleAggregateModel {
     }
 
     func remove(cartItem: CartItem) {
-        cart.remove(cartItem)
-    }
-
-    func remove(cartCouponItem: CartCouponItem) {
-        cart.remove(cartCouponItem)
-    }
-
-    func removeAllItemsFromCart() {
-        cart.removeAll()
+        switch cartItem.type {
+        case .purchasableItem:
+            cart.purchasableItems.removeAll { $0.id == cartItem.id }
+        case .coupon:
+            cart.coupons.removeAll { $0.id == cartItem.id }
+        }
     }
 
-    func removeAllCouponsFromCart() {
-        cart.coupons.removeAll()
+    func removeAllItemsFromCart(types: [CartItemType] =  CartItemType.allCases) {
path: WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
user: login: joshheald
body: Nice default use
created_at: 2025-04-17T17:27:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,7 +26,7 @@ struct CartView: View {
     @State private var shouldShowItemImages: Bool = false
 
     private var shouldShowCoupons: Bool {
-        ServiceLocator.featureFlagService.isFeatureFlagEnabled(.enableCouponsInPointOfSale) && posModel.cart.coupons.isNotEmpty
+        ServiceLocator.featureFlagService.isFeatureFlagEnabled(.enableCouponsInPointOfSale) && !posModel.cart.coupons.isEmpty
path: WooCommerce/Classes/POS/Presentation/CartView.swift
user: login: joshheald
body: Why the swap here? I find the wordy version is easier to read than "&& !"
created_at: 2025-04-17T17:28:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: dangermattic
created_at: 2025-04-14T19:13:19Z
body: <!--
  0 Errors
  2 Warnings: View files have been modified,...
  0 Messages
  0 Markdowns
-->
<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true" data-kind="Warning">
          2 Warnings
      </th>
     </tr>
  </thead>
  <tbody>
    <tr>
      <td>:warning:</td>
      <td data-sticky="false">View files have been modified, but no screenshot or video is included in the pull request. Consider adding some for clarity.</td>
    </tr>
    <tr>
      <td>:warning:</td>
      <td data-sticky="false">This PR is assigned to the milestone <a href="https://github.com/woocommerce/woocommerce-ios/milestone/242">22.2</a>. The due date for this milestone has already passed.<br />
Please assign it to a milestone with a later deadline or check whether the release for this milestone has already been finished.</td>
    </tr>
  </tbody>
</table>

<p align="right" data-meta="generated_by_pr-check">
  Generated by :no_entry_sign: <a href="https://danger.systems/">Danger</a>
</p>

author_association: COLLABORATOR
user: login: wpmobilebot
created_at: 2025-04-14T19:22:35Z
body: <!-- REUSE_ID: prototype-build-link -->

<p><img align='top' src='https://raw.githubusercontent.com/buildkite/emojis/main/img-buildkite-64/firebase.png' width='20px' alt='App Icon' />📲 You can test the changes from this Pull Request in <b>WooCommerce iOS Prototype</b> by scanning the QR code below to install the corresponding build.</p>
<table>
<tr>
  <td rowspan='6' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fappdistribution.firebase.google.com%2Ftesterapps%2F1%3A124902176124%3Aios%3A02259de1e7c42b291620f9%2Freleases%2F5am4dqsbv46a8%3Futm_source%3Dfastlane' width='250' height='250' /></td>
  <td><b>App Name</b></td><td>WooCommerce iOS Prototype</td>
</tr>
<tr><td><b>Build Number</b></td><td><code>29373</code></td></tr>
<tr><td><b>Version</b></td><td><code>PR #15508</code></td></tr>
<tr><td><b>Bundle ID</b></td><td><code>com.automattic.alpha.woocommerce</code></td></tr>
<tr><td><b>Commit</b></td><td>12c1bbd4531c21541da5042b474d37da44c69e5d</td></tr>
<tr><td><b>Installation URL</b></td><td><a href='https://appdistribution.firebase.google.com/testerapps/1:124902176124:ios:02259de1e7c42b291620f9/releases/5am4dqsbv46a8?utm_source=fastlane'>5am4dqsbv46a8</a></td></tr>
</table>
<em>Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.</em>

author_association: COLLABORATOR
user: login: staskus
created_at: 2025-04-17T10:00:15Z
body: @joshheald, thanks for the review and candid thoughts.

I stayed in the less-clumsy lane now and tried to simplify the aggregate model interface without exposing the setter of `Cart`.

I used a combination of `protocol` and `enum`. Turned `CartItem` into protocol that represents an item with `id: UUID` and `CartItemType`:
- .purchasableItem
- .coupon

This way we can identify both specific items and their types.

Mind, this review is not urgent, doesn't block a project.
author_association: CONTRIBUTOR