diff --git a/Modules/Sources/NetworkingCore/Network/NetworkError.swift b/Modules/Sources/NetworkingCore/Network/NetworkError.swift index 823151ceffc..1a08decc14b 100644 --- a/Modules/Sources/NetworkingCore/Network/NetworkError.swift +++ b/Modules/Sources/NetworkingCore/Network/NetworkError.swift @@ -51,7 +51,7 @@ public enum NetworkError: Error, Equatable { } /// Content of the `code` field in the response if available - var errorCode: String? { + public var errorCode: String? { guard let response else { return nil } let decoder = JSONDecoder() guard let decodedResponse = try? decoder.decode(NetworkErrorResponse.self, from: response) else { @@ -59,6 +59,16 @@ public enum NetworkError: Error, Equatable { } return decodedResponse.code } + + /// Content of the `data` field in the response if available + public var errorData: [String: AnyDecodable]? { + guard let response else { return nil } + let decoder = JSONDecoder() + guard let decodedResponse = try? decoder.decode(NetworkErrorResponse.self, from: response) else { + return nil + } + return decodedResponse.data + } } @@ -134,6 +144,7 @@ extension NetworkError: CustomStringConvertible { struct NetworkErrorResponse: Decodable { let code: String? + let data: [String: AnyDecodable]? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -144,6 +155,7 @@ struct NetworkErrorResponse: Decodable { } return try container.decodeIfPresent(String.self, forKey: .code) }() + self.data = try container.decodeIfPresent([String: AnyDecodable].self, forKey: .data) } /// Coding Keys @@ -151,5 +163,6 @@ struct NetworkErrorResponse: Decodable { private enum CodingKeys: String, CodingKey { case error case code + case data } } diff --git a/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift b/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift index 0ccca35179b..b1f6cec3cf3 100644 --- a/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift +++ b/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift @@ -44,6 +44,8 @@ extension WooAnalyticsEvent { static let listPosition = "list_position" static let daysSinceCreated = "days_since_created" static let pageNumber = "page_number" + static let reason = "reason" + static let syncStrategy = "sync_strategy" } /// Source of the event where the event is triggered @@ -457,6 +459,47 @@ extension WooAnalyticsEvent { static func ordersListLoaded() -> WooAnalyticsEvent { WooAnalyticsEvent(statName: .ordersListLoaded, properties: [:]) } + + // MARK: - Checkout Outdated Item Detection Events + + static func checkoutOutdatedItemDetectedScreenShown( + reason: String, + syncStrategy: String + ) -> WooAnalyticsEvent { + WooAnalyticsEvent( + statName: .pointOfSaleCheckoutOutdatedItemDetectedScreenShown, + properties: [ + Key.reason: reason, + Key.syncStrategy: syncStrategy + ] + ) + } + + static func checkoutOutdatedItemDetectedEditOrderTapped( + reason: String, + syncStrategy: String + ) -> WooAnalyticsEvent { + WooAnalyticsEvent( + statName: .pointOfSaleCheckoutOutdatedItemDetectedEditOrderTapped, + properties: [ + Key.reason: reason, + Key.syncStrategy: syncStrategy + ] + ) + } + + static func checkoutOutdatedItemDetectedRemoveTapped( + reason: String, + syncStrategy: String + ) -> WooAnalyticsEvent { + WooAnalyticsEvent( + statName: .pointOfSaleCheckoutOutdatedItemDetectedRemoveTapped, + properties: [ + Key.reason: reason, + Key.syncStrategy: syncStrategy + ] + ) + } } } diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleOrderController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleOrderController.swift index 5b17b5f4a6e..9206f423127 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleOrderController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleOrderController.swift @@ -3,6 +3,7 @@ import Observation import protocol Experiments.FeatureFlagService import class WooFoundation.VersionHelpers import protocol Yosemite.POSOrderServiceProtocol +import class Yosemite.POSOrderService import protocol Yosemite.POSReceiptServiceProtocol import protocol Yosemite.PluginsServiceProtocol import protocol Yosemite.PaymentCaptureCelebrationProtocol @@ -11,6 +12,7 @@ import struct Yosemite.Order import struct Yosemite.POSCart import struct Yosemite.POSCartItem import struct Yosemite.POSCoupon +import struct Yosemite.POSVariation import struct Yosemite.CouponsError import enum Yosemite.OrderAction import enum Yosemite.OrderUpdateField @@ -20,7 +22,6 @@ import class WooFoundation.CurrencySettings import class Yosemite.PluginsService import enum WooFoundation.CurrencyCode import protocol WooFoundation.Analytics -import enum Alamofire.AFError import class Yosemite.OrderTotalsCalculator import struct WooFoundation.WooAnalyticsEvent import protocol WooFoundationCore.WooAnalyticsEventPropertyType @@ -188,17 +189,25 @@ private extension PointOfSaleOrderController { private extension PointOfSaleOrderController { func orderStateError(from error: Error) -> PointOfSaleOrderState.OrderStateError { - if let couponsError = CouponsError(underlyingError: error) { + // Check for missing products error first + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + let missingProductInfo = missingItems.map { + PointOfSaleOrderState.OrderStateError.MissingProductInfo( + productID: $0.productID, + variationID: $0.variationID, + name: $0.name + ) + } + return .missingProducts(missingProductInfo) + } + else if let couponsError = CouponsError(underlyingError: error) { return .invalidCoupon(couponsError.message) - } else if let afErrorDescription = (error as? AFError)?.underlyingError?.localizedDescription { - return .other(afErrorDescription) } else { return .other(error.localizedDescription) } } } - // This is named to note that it is for use within the AggregateModel and OrderController. // Conversely, PointOfSaleOrderState is available to the Views, as it doesn't include the Order. enum PointOfSaleInternalOrderState { @@ -261,6 +270,8 @@ private extension PointOfSaleOrderController { if let _ = CouponsError(underlyingError: error) { errorType = .invalidCoupon + } else if case .missingProductsInOrder = error as? POSOrderService.POSOrderServiceError { + errorType = .missingProducts } analytics.track(event: WooAnalyticsEvent.Orders.orderCreationFailed( @@ -290,9 +301,9 @@ private extension WooAnalyticsEvent { // MARK: - Order Creation Events /// Matches errors on Android for consistency - /// Only coupon tracking is relevant for now enum OrderCreationErrorType: String { case invalidCoupon = "INVALID_COUPON" + case missingProducts = "MISSING_PRODUCTS" } static func orderCreationFailed( diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index ae68bda4fef..822bf349858 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -17,6 +17,8 @@ import enum Yosemite.PointOfSaleBarcodeScanError import protocol Yosemite.POSCatalogSyncCoordinatorProtocol import class Yosemite.POSCatalogSyncCoordinator import enum Yosemite.CardReaderSoftwareUpdateState +import struct Yosemite.POSSimpleProduct +import struct Yosemite.POSVariation protocol PointOfSaleAggregateModelProtocol { var cart: Cart { get } @@ -193,6 +195,48 @@ extension PointOfSaleAggregateModel { paymentState = .idle cardPresentPaymentInlineMessage = nil } + + /// Removes missing products from the cart only (catalog is auto-cleaned when errors are detected) + /// - Parameters: + /// - productIDs: Product IDs to remove (for simple products) + /// - variationIDs: Variation IDs to remove (for variations) + func removeMissingProductsFromCart(productIDs: Set, variationIDs: Set) { + cart.purchasableItems.removeAll { item in + guard case .loaded(let orderableItem) = item.state else { return false } + + // Check if it's a simple product matching the product IDs + if let simpleProduct = orderableItem as? POSSimpleProduct { + return productIDs.contains(simpleProduct.productID) + } + // Check if it's a variation matching the variation IDs + else if let variation = orderableItem as? POSVariation { + return variationIDs.contains(variation.productVariationID) + } + return false + } + } + + /// Removes identified missing products from the catalog only (not from cart) + /// - Parameter missingProducts: Array of missing product info + private func removeIdentifiedMissingProductsFromCatalog(_ missingProducts: [PointOfSaleOrderState.OrderStateError.MissingProductInfo]) async { + let (productIDs, variationIDs) = missingProducts.extractProductAndVariationIDs() + + // Remove from local catalog only if we have identifiable products + guard !productIDs.isEmpty || !variationIDs.isEmpty else { return } + + if let catalogSyncCoordinator { + do { + try await catalogSyncCoordinator.deleteProductsFromCatalog( + Array(productIDs), + variationIDs: Array(variationIDs), + siteID: siteID + ) + DDLogInfo("🗑️ Auto-removed \(productIDs.count) products and \(variationIDs.count) variations from local catalog (unavailable items)") + } catch { + DDLogError("⚠️ Failed to auto-remove unavailable products from local catalog: \(error)") + } + } + } } // MARK: - Barcode Scanning @@ -619,8 +663,18 @@ extension PointOfSaleAggregateModel { await self?.checkOut() }) trackOrderSyncState(syncOrderResult) + await removeMissingProductsFromCatalogAfterSync() await startPaymentWhenCardReaderConnected() } + + /// Removes unavailable products from the local catalog after detecting them during order sync + @MainActor + private func removeMissingProductsFromCatalogAfterSync() async { + // If we identified specific missing products, remove them from the catalog immediately + if case .error(.missingProducts(let missingProducts), _) = orderController.orderState.externalState { + await removeIdentifiedMissingProductsFromCatalog(missingProducts) + } + } } // MARK: - Lifecycle diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleOrderState.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleOrderState.swift index 8813f9077d2..dd747a10963 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleOrderState.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleOrderState.swift @@ -11,6 +11,13 @@ enum PointOfSaleOrderState: Equatable { enum OrderStateError: Equatable { case other(String) case invalidCoupon(String) + case missingProducts([MissingProductInfo]) + + struct MissingProductInfo: Equatable { + let productID: Int64 + let variationID: Int64 + let name: String + } static func == (lhs: OrderStateError, rhs: OrderStateError) -> Bool { switch (lhs, rhs) { @@ -18,6 +25,8 @@ enum PointOfSaleOrderState: Equatable { return lhsError == rhsError case (.invalidCoupon(let lhsCoupon), .invalidCoupon(let rhsCoupon)): return lhsCoupon == rhsCoupon + case (.missingProducts(let lhsProducts), .missingProducts(let rhsProducts)): + return lhsProducts == rhsProducts default: return false } @@ -64,3 +73,27 @@ enum PointOfSaleOrderState: Equatable { } } } + +// MARK: - Missing Product Helpers +extension Array where Element == PointOfSaleOrderState.OrderStateError.MissingProductInfo { + /// Extracts product and variation IDs from missing product info + /// Returns a tuple of (productIDs, variationIDs) containing only non-zero IDs + func extractProductAndVariationIDs() -> (productIDs: Set, variationIDs: Set) { + var productIDs = Set() + var variationIDs = Set() + + for missingProduct in self { + // If variationID is non-zero, it's a variation + if missingProduct.variationID != 0 { + variationIDs.insert(missingProduct.variationID) + } + // If productID is non-zero (and variationID is zero), it's a simple product + else if missingProduct.productID != 0 { + productIDs.insert(missingProduct.productID) + } + // Skip items with both IDs as 0 (generic errors where we can't identify the product) + } + + return (productIDs, variationIDs) + } +} diff --git a/Modules/Sources/PointOfSale/Presentation/Order Messages/PointOfSaleOrderSyncMissingProductsErrorMessageView.swift b/Modules/Sources/PointOfSale/Presentation/Order Messages/PointOfSaleOrderSyncMissingProductsErrorMessageView.swift new file mode 100644 index 00000000000..4308fcb832a --- /dev/null +++ b/Modules/Sources/PointOfSale/Presentation/Order Messages/PointOfSaleOrderSyncMissingProductsErrorMessageView.swift @@ -0,0 +1,213 @@ +import SwiftUI + +struct PointOfSaleOrderSyncMissingProductsErrorMessageView: View { + let missingProducts: [PointOfSaleOrderState.OrderStateError.MissingProductInfo] + let retryHandler: () -> Void + + @Environment(PointOfSaleAggregateModel.self) private var posModel + @Environment(\.dynamicTypeSize) var dynamicTypeSize + @Environment(\.posAnalytics) private var analytics + + var body: some View { + GeometryReader { geometry in + HStack(alignment: .center) { + Spacer() + VStack(alignment: .center, spacing: POSSpacing.none) { + Spacer() + POSErrorXMark() + Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.imageAndTextSpacing) + VStack(alignment: .center, spacing: PointOfSaleEmptyErrorStateViewLayout.textSpacing) { + Text(title) + .foregroundStyle(Color.posOnSurface) + .font(.posHeadingBold) + + Text(subtitle) + .foregroundStyle(Color.posOnSurface) + .font(.posBodyLargeRegular()) + .padding([.leading, .trailing]) + } + Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.textAndButtonSpacing) + + VStack(spacing: PointOfSaleEmptyErrorStateViewLayout.buttonSpacing) { + Button(Localization.editOrderTitle, action: { + let syncStrategy = posModel.isLocalCatalogEligible ? "local_catalog" : "remote" + analytics.track(event: .PointOfSale.checkoutOutdatedItemDetectedEditOrderTapped( + reason: "deleted", + syncStrategy: syncStrategy + )) + posModel.addMoreToCart() + }) + .buttonStyle(POSFilledButtonStyle(size: .normal)) + + // Only show "Remove product" button if we can identify specific products + if canIdentifySpecificProducts { + Button(removeProductsActionTitle, action: { + let syncStrategy = posModel.isLocalCatalogEligible ? "local_catalog" : "remote" + + // Track the new specific event + analytics.track(event: .PointOfSale.checkoutOutdatedItemDetectedRemoveTapped( + reason: "deleted", + syncStrategy: syncStrategy + )) + + // Keep existing generic event for backwards compatibility + analytics.track(event: .PointOfSale.itemRemovedFromCart( + sourceView: .error, + itemType: .product + )) + + removeMissingProductsFromCart() + retryHandler() + }) + .buttonStyle(POSOutlinedButtonStyle(size: .normal)) + } + } + .frame(width: geometry.size.width / 2) + .padding([.leading, .trailing], Constants.buttonSidePadding) + .padding([.bottom], Constants.buttonBottomPadding) + + Spacer() + } + .multilineTextAlignment(.center) + Spacer() + } + } + .onAppear { + let syncStrategy = posModel.isLocalCatalogEligible ? "local_catalog" : "remote" + analytics.track(event: .PointOfSale.checkoutOutdatedItemDetectedScreenShown( + reason: "deleted", + syncStrategy: syncStrategy + )) + } + } + + private var title: String { + if missingProducts.count == 1 { + return Localization.titleSingular + } else { + return Localization.titlePlural + } + } + + private var subtitle: String { + if missingProducts.count == 1, + let productName = missingProducts.first?.name { + if productName == Localization.unknownProductName { + return Localization.subtitleGenericProduct + } else { + return String(format: Localization.subtitleSingular, productName) + } + } else { + return Localization.subtitlePlural + } + } + + private var removeProductsActionTitle: String { + if missingProducts.count == 1 { + return Localization.removeActionTitleSingular + } else { + return Localization.removeActionTitlePlural + } + } + + /// Returns true if we can identify specific products to remove + /// Returns false for generic errors where productID=0 and variationID=0 + private var canIdentifySpecificProducts: Bool { + // If all missing products have both IDs as 0, we can't identify them + return !missingProducts.allSatisfy { $0.productID == 0 && $0.variationID == 0 } + } + + private func removeMissingProductsFromCart() { + let (productIDs, variationIDs) = missingProducts.extractProductAndVariationIDs() + // Remove items from cart only (catalog was already cleaned up when error was detected) + posModel.removeMissingProductsFromCart(productIDs: productIDs, variationIDs: variationIDs) + } +} + +private extension PointOfSaleOrderSyncMissingProductsErrorMessageView { + enum Constants { + static let buttonSidePadding: CGFloat = POSPadding.xxLarge + static let buttonBottomPadding: CGFloat = POSPadding.medium + } +} + +private extension PointOfSaleOrderSyncMissingProductsErrorMessageView { + enum Localization { + static let titleSingular = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.titleSingular", + value: "Product no longer available", + comment: "Title of the error when a single product in the cart is no longer available" + ) + + static let titlePlural = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.titlePlural", + value: "Products no longer available", + comment: "Title of the error when multiple products in the cart are no longer available" + ) + + static let subtitleSingular = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.subtitleSingular", + value: "%@ is no longer available.", + comment: "Subtitle of the error when a single product is no longer available. Placeholder is the product name." + ) + + static let subtitleGenericProduct = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.subtitleGenericProduct", + value: "A product in the cart is no longer available.", + comment: "Subtitle of the error when we can't identify which specific product is no longer available." + ) + + static let subtitlePlural = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.subtitlePlural", + value: "Some products in your cart are no longer available.", + comment: "Subtitle of the error when multiple products are no longer available" + ) + + static let removeActionTitleSingular = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.removeProductSingular", + value: "Remove product", + comment: "Button title to remove a single unavailable product and retry creating the order" + ) + + static let removeActionTitlePlural = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.removeProductsPlural", + value: "Remove products", + comment: "Button title to remove multiple unavailable products and retry creating the order" + ) + + static let editOrderTitle = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.editOrder", + value: "Edit order", + comment: "Button to return to order editing when products are no longer available" + ) + + static let unknownProductName = NSLocalizedString( + "pointOfSale.orderSync.missingProductsError.unknownProductName", + value: "One or more products", + comment: "Generic product name used when we can't identify which specific product is unavailable" + ) + } +} + +#if DEBUG +#Preview("Single Missing Product") { + PointOfSaleOrderSyncMissingProductsErrorMessageView( + missingProducts: [ + PointOfSaleOrderState.OrderStateError.MissingProductInfo(productID: 100, variationID: 0, name: "Blue T-Shirt") + ], + retryHandler: {} + ) + .environment(POSPreviewHelpers.makePreviewAggregateModel()) +} + +#Preview("Multiple Missing Products") { + PointOfSaleOrderSyncMissingProductsErrorMessageView( + missingProducts: [ + PointOfSaleOrderState.OrderStateError.MissingProductInfo(productID: 100, variationID: 0, name: "Blue T-Shirt"), + PointOfSaleOrderState.OrderStateError.MissingProductInfo(productID: 0, variationID: 500, name: "Red Hat") + ], + retryHandler: {} + ) + .environment(POSPreviewHelpers.makePreviewAggregateModel()) +} +#endif diff --git a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift index 7cd2e28f308..43f9118d182 100644 --- a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift @@ -70,6 +70,9 @@ struct TotalsView: View { case .error(.invalidCoupon(let message), let handler): PointOfSaleOrderSyncCouponsErrorMessageView(message: message, retryHandler: handler) .transition(.opacity) + case .error(.missingProducts(let missingProducts), let handler): + PointOfSaleOrderSyncMissingProductsErrorMessageView(missingProducts: missingProducts, retryHandler: handler) + .transition(.opacity) } } .background(backgroundColor) diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 828ab2050b2..ead13993bec 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -654,6 +654,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws { // no-op } + + func deleteProductsFromCatalog(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws { + // no-op + } } #endif diff --git a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift index 2b44a7c5230..387c951398c 100644 --- a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift +++ b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift @@ -1315,6 +1315,9 @@ public enum WooAnalyticsStat: String { case pointOfSaleLocalCatalogSyncCompleted = "local_catalog_sync_completed" case pointOfSaleLocalCatalogSyncFailed = "local_catalog_sync_failed" case pointOfSaleLocalCatalogSyncSkipped = "local_catalog_sync_skipped" + case pointOfSaleCheckoutOutdatedItemDetectedScreenShown = "checkout_outdated_item_detected_screen_shown" + case pointOfSaleCheckoutOutdatedItemDetectedEditOrderTapped = "checkout_outdated_item_detected_edit_order_tapped" + case pointOfSaleCheckoutOutdatedItemDetectedRemoveTapped = "checkout_outdated_item_detected_remove_tapped" // MARK: Custom Fields events case productDetailCustomFieldsTapped = "product_detail_custom_fields_tapped" diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCart.swift b/Modules/Sources/Yosemite/Tools/POS/POSCart.swift index 532bd818147..b481c541afc 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCart.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCart.swift @@ -27,6 +27,55 @@ public extension POSCart { func matches(order: Order?) -> Bool { return items.matches(order: order) && coupons.matches(order: order) } + + func compareWithOrder(_ order: Order?) -> CartOrderComparison { + let itemsComparison = items.compareWithOrder(order) + let couponsMatch = coupons.matches(order: order) + + return CartOrderComparison( + missingItems: itemsComparison.missingItems, + quantityMismatches: itemsComparison.quantityMismatches, + couponsMatch: couponsMatch + ) + } +} + +/// Represents the result of comparing a cart with an order to detect discrepancies +public struct CartOrderComparison { + /// Items that were expected in the cart but are missing from the order + public let missingItems: [MissingCartItem] + /// Items where the quantity in the order doesn't match the cart + public let quantityMismatches: [QuantityMismatch] + /// Whether the coupons in the cart match the order + public let couponsMatch: Bool + + /// Returns true if there are any discrepancies between cart and order + public var hasDiscrepancies: Bool { + return !missingItems.isEmpty || !quantityMismatches.isEmpty || !couponsMatch + } + + /// Represents an item that was expected in the cart but is missing from the order + public struct MissingCartItem { + /// The product ID (for simple products) or parent product ID (for variations) + public let productID: Int64 + /// The variation ID (0 for simple products) + public let variationID: Int64 + /// The product or variation name + public let name: String + } + + /// Represents an item where the quantity doesn't match between cart and order + public struct QuantityMismatch { + /// The product or variation name + // periphery:ignore - part of public API + public let name: String + /// The quantity in the cart + // periphery:ignore - part of public API + public let expectedQuantity: Decimal + /// The quantity in the order + // periphery:ignore - part of public API + public let actualQuantity: Decimal + } } extension [POSCartItem] { @@ -67,6 +116,99 @@ extension [POSCartItem] { return true } + func compareWithOrder(_ order: Order?) -> ItemsComparison { + guard let order else { + // If there's no order but we have items, all items are missing + let missingItems = self.compactMap { cartItem -> CartOrderComparison.MissingCartItem? in + guard let (productID, variationID) = extractProductIDs(from: cartItem.item) else { + return nil + } + return CartOrderComparison.MissingCartItem( + productID: productID, + variationID: variationID, + name: cartItem.item.name + ) + } + return ItemsComparison(missingItems: missingItems, quantityMismatches: []) + } + + // Group cart items by product/variation ID to consolidate duplicates + let cartItemsByProductKey = Dictionary(grouping: self, by: { item -> String in + guard let (productID, variationID) = extractProductIDs(from: item.item) else { + // Use a unique UUID for each invalid item to prevent grouping them together. + // This ensures each invalid item is reported separately in error handling + // rather than being masked as a single consolidated entry. + return "invalid_\(UUID())" + } + return variationID != 0 ? "variation_\(variationID)" : "product_\(productID)" + }) + + // Calculate total quantity for each product/variation in cart + let cartQuantities = cartItemsByProductKey.mapValues { items in + items.reduce(Decimal(0)) { $0 + $1.quantity } + } + + // Group order items by product/variation ID + let orderQuantities = Dictionary(grouping: order.items, by: { (item: OrderItem) -> String in + item.variationID != 0 ? "variation_\(item.variationID)" : "product_\(item.productID)" + }).mapValues { items in + items.reduce(Decimal(0)) { $0 + $1.quantity } + } + + var missingItems: [CartOrderComparison.MissingCartItem] = [] + var quantityMismatches: [CartOrderComparison.QuantityMismatch] = [] + + // Check each unique product/variation in cart + for (key, cartItems) in cartItemsByProductKey { + guard let firstItem = cartItems.first, + let (productID, variationID) = extractProductIDs(from: firstItem.item) else { + continue + } + + let expectedQuantity = cartQuantities[key] ?? 0 + let actualQuantity = orderQuantities[key] ?? 0 + + if actualQuantity == 0 { + // Item is in cart but not in order at all + missingItems.append( + CartOrderComparison.MissingCartItem( + productID: productID, + variationID: variationID, + name: firstItem.item.name + ) + ) + } else if actualQuantity != expectedQuantity { + // Item is in both but quantities don't match + quantityMismatches.append( + CartOrderComparison.QuantityMismatch( + name: firstItem.item.name, + expectedQuantity: expectedQuantity, + actualQuantity: actualQuantity + ) + ) + } + } + + return ItemsComparison(missingItems: missingItems, quantityMismatches: quantityMismatches) + } + + private func extractProductIDs(from item: POSOrderableItem) -> (productID: Int64, variationID: Int64)? { + // Check if it's a simple product + if let simpleProduct = item as? POSSimpleProduct { + return (simpleProduct.productID, 0) + } + // Check if it's a variation + else if let variation = item as? POSVariation { + return (variation.productID, variation.productVariationID) + } + return nil + } + + struct ItemsComparison { + let missingItems: [CartOrderComparison.MissingCartItem] + let quantityMismatches: [CartOrderComparison.QuantityMismatch] + } + func createGroupedOrderSyncProductInputs() -> [OrderSyncProductInput.ProductType: OrderSyncProductInput] { let orderSyncProductInputs = self.map { $0.item.toOrderSyncProductInput(quantity: $0.quantity) } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift index 263b52472c7..25482efb111 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift @@ -15,6 +15,13 @@ protocol POSCatalogPersistenceServiceProtocol { /// - catalog: The catalog difference to persist /// - siteID: The site ID to associate the catalog with func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws + + /// Deletes specific products and/or variations from the catalog + /// - Parameters: + /// - productIDs: Product IDs to delete + /// - variationIDs: Variation IDs to delete + /// - siteID: The site ID + func deleteProducts(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws } final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { @@ -148,6 +155,32 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { "\(variationImageCount) variation images, \(variationAttributeCount) variation attributes") } } + + func deleteProducts(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws { + DDLogInfo("🗑️ Deleting \(productIDs.count) products and \(variationIDs.count) variations from catalog") + + try await grdbManager.databaseConnection.write { db in + // Batch delete products using filter + if !productIDs.isEmpty { + let deletedProductsCount = try PersistedProduct + .filter(PersistedProduct.Columns.siteID == siteID) + .filter(productIDs.contains(PersistedProduct.Columns.id)) + .deleteAll(db) + DDLogInfo("Deleted \(deletedProductsCount) products from catalog") + } + + // Batch delete variations using filter + if !variationIDs.isEmpty { + let deletedVariationsCount = try PersistedProductVariation + .filter(PersistedProductVariation.Columns.siteID == siteID) + .filter(variationIDs.contains(PersistedProductVariation.Columns.id)) + .deleteAll(db) + DDLogInfo("Deleted \(deletedVariationsCount) variations from catalog") + } + } + + DDLogInfo("✅ Catalog deletion complete") + } } private extension POSCatalogPersistenceService { diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 00070b02563..6d1c210d836 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -63,6 +63,13 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// - fileURL: Local file URL of the downloaded catalog /// - siteID: Site ID for this catalog func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws + + /// Deletes specific products and/or variations from the local catalog + /// - Parameters: + /// - productIDs: Product IDs to delete + /// - variationIDs: Variation IDs to delete + /// - siteID: The site ID + func deleteProductsFromCatalog(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws } public extension POSCatalogSyncCoordinatorProtocol { @@ -650,6 +657,11 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { return "unknown_reason" } + + public func deleteProductsFromCatalog(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws { + let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager) + try await persistenceService.deleteProducts(productIDs, variationIDs: variationIDs, siteID: siteID) + } } // MARK: - Syncing State diff --git a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift index 270e2436dbb..8874e0caa24 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift @@ -4,6 +4,7 @@ import class WooFoundation.CurrencyFormatter import enum WooFoundation.CurrencyCode import struct Combine.AnyPublisher import struct NetworkingCore.JetpackSite +import enum Alamofire.AFError public protocol POSOrderServiceProtocol { /// Syncs order based on the cart. @@ -50,7 +51,36 @@ public final class POSOrderService: POSOrderServiceProtocol { .addItems(cart.items) .addCoupons(cart.coupons) - return try await ordersRemote.createPOSOrder(siteID: siteID, order: order, fields: [.items, .status, .currency, .couponLines]) + let createdOrder: Order + do { + createdOrder = try await ordersRemote.createPOSOrder( + siteID: siteID, + order: order, + fields: [.items, .status, .currency, .couponLines] + ) + } catch { + // Check if this is a server validation error about missing products + if let missingItems = extractMissingProductsFromServerError(error, cart: cart) { + throw POSOrderServiceError.missingProductsInOrder(missingItems) + } + throw error + } + + // Validate that the created order contains all cart items + let comparison = cart.compareWithOrder(createdOrder) + if comparison.hasDiscrepancies { + DDLogWarn(""" + ⚠️ Order created but cart-order mismatch detected. \ + Missing items: \(comparison.missingItems.count), \ + Quantity mismatches: \(comparison.quantityMismatches.count) + """) + + if !comparison.missingItems.isEmpty { + throw POSOrderServiceError.missingProductsInOrder(comparison.missingItems) + } + } + + return createdOrder } public func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws { @@ -99,18 +129,137 @@ private extension Order { } } -private extension POSOrderService { +public extension POSOrderService { enum POSOrderServiceError: Error { case updateOrderFailed + case missingProductsInOrder([CartOrderComparison.MissingCartItem]) } } private extension POSOrderService { + /// Extracts missing product information from server validation errors + /// Handles cases where the server rejects order creation due to invalid product/variation IDs + func extractMissingProductsFromServerError( + _ error: Error, + cart: POSCart + ) -> [CartOrderComparison.MissingCartItem]? { + // Check if this is an AFError wrapping a DotcomError or NetworkError + let underlyingError: Error? = { + if let afError = error as? AFError { + return afError.underlyingError + } + return error + }() + + // Check for DotcomError with product/variation validation error codes + if case .unknown(let code, _) = underlyingError as? DotcomError { + if isProductValidationError(code: code) { + // DotcomError doesn't include data field, fall back to generic error + return extractMissingProductsFromCart() + } + } + + // Check for NetworkError with product/variation validation error codes + if let networkError = underlyingError as? NetworkError, + let errorCode = networkError.errorCode, + isProductValidationError(code: errorCode) { + // Try to extract variation_id from NetworkError data if available + if let variationID = extractVariationID(from: networkError.errorData) { + return createMissingProductInfo(forVariationID: variationID, cart: cart) + } + // Fall back to generic error if no variation_id in data + return extractMissingProductsFromCart() + } + + return nil + } + + /// Extracts variation_id from error data dictionary + func extractVariationID(from data: [String: AnyDecodable]?) -> Int64? { + guard let data = data, + let variationIDValue = data["variation_id"]?.value else { + return nil + } + + // Handle different number types + if let intValue = variationIDValue as? Int { + return Int64(intValue) + } else if let int64Value = variationIDValue as? Int64 { + return int64Value + } + return nil + } + + /// Creates MissingCartItem for a specific variation ID + /// Searches the cart to find the variation's name and parent product ID + func createMissingProductInfo( + forVariationID variationID: Int64, + cart: POSCart + ) -> [CartOrderComparison.MissingCartItem] { + // Search the cart for the variation to get its name + let cartItem = cart.items.first { item in + if let variation = item.item as? POSVariation { + return variation.productVariationID == variationID + } + return false + } + + let productName: String + let parentProductID: Int64 + + if let cartItem = cartItem, + let variation = cartItem.item as? POSVariation { + // Found the variation in the cart - use its parent product name and variation name + productName = "\(variation.parentProductName) - \(variation.name)" + parentProductID = variation.productID + } else { + // Couldn't find it in cart, use generic name + productName = Localization.unknownProductName + parentProductID = 0 + } + + return [ + CartOrderComparison.MissingCartItem( + productID: parentProductID, + variationID: variationID, + name: productName + ) + ] + } + + /// Checks if an error code indicates a product validation error + /// Currently only handles the confirmed error code from WooCommerce server responses + func isProductValidationError(code: String) -> Bool { + // Only check for the one confirmed error code we've observed + // Additional codes can be added as they are discovered through testing + return code == "order_item_product_invalid_variation_id" + } + + /// Extracts missing products by trying to identify items in cart that might have caused the validation error + /// Since server doesn't tell us which specific products failed, we return generic error info + func extractMissingProductsFromCart() -> [CartOrderComparison.MissingCartItem]? { + // We can't determine which specific products are invalid from the server error + // So we return a generic missing product message with 0 for both IDs (meaning unknown) + // The user will need to remove all products and retry + return [ + CartOrderComparison.MissingCartItem( + productID: 0, + variationID: 0, + name: Localization.unknownProductName + ) + ] + } + enum Localization { static let cashPaymentMethodTitle = NSLocalizedString( "pointOfSaleOrderController.collectCashPayment.paymentMethodTitle", value: "Pay in Person", comment: "Title for the payment method used when collecting cash payment in Point of Sale." ) + static let unknownProductName = NSLocalizedString( + "pointOfSale.orderController.unknownProduct", + value: "One or more products", + comment: "Fallback name for a product that couldn't be identified in error handling." + ) } } diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift index ec09a005f03..902a8565c39 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -100,4 +100,8 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { throw error } } + + func deleteProductsFromCatalog(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws { + // no-op + } } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift index ff945a5cb81..17fb850150f 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift @@ -31,4 +31,8 @@ final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtoc throw error } } + + func deleteProducts(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws { + // no-op + } } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index f61bf1564ce..e373def3ee4 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -44,13 +44,26 @@ final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { var createPOSOrderCalled: Bool = false var spyCreatePOSOrder: Order? var spyCreatePOSOrderFields: [OrdersRemote.CreateOrderField]? + var createPOSOrderResult: Result? func createPOSOrder(siteID: Int64, order: Networking.Order, fields: [OrdersRemote.CreateOrderField]) async throws -> Order { createPOSOrderCalled = true spyCreatePOSOrder = order spyCreatePOSOrderFields = fields - return Order.fake() + + // If a custom result is set, use it + if let result = createPOSOrderResult { + switch result { + case .success(let customOrder): + return customOrder + case .failure(let error): + throw error + } + } + + // By default, return the order that was passed in (simulating server echo) + return order } var mockPagedOrdersResult: Result, Error> = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift index 8c1f64df95c..5a0e63c0d17 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift @@ -697,6 +697,150 @@ struct POSCatalogPersistenceServiceTests { #expect(variationImage?.imageID == 100) } } + + // MARK: - Delete Products Tests + + @Test func deleteProducts_removes_specified_products_from_catalog() async throws { + // Given + let catalog = POSCatalog( + products: [ + POSProduct.fake().copy(siteID: sampleSiteID, productID: 100), + POSProduct.fake().copy(siteID: sampleSiteID, productID: 200), + POSProduct.fake().copy(siteID: sampleSiteID, productID: 300) + ], + variations: [], + syncDate: .now + ) + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // When + try await sut.deleteProducts([100, 300], variationIDs: [], siteID: sampleSiteID) + + // Then + try await db.read { db in + let products = try PersistedProduct + .filter(sql: "\(PersistedProduct.Columns.siteID.name) = \(sampleSiteID)") + .fetchAll(db) + #expect(products.count == 1) + #expect(products.first?.id == 200) + } + } + + @Test func deleteProducts_removes_specified_variations_from_catalog() async throws { + // Given + let catalog = POSCatalog( + products: [ + POSProduct.fake().copy(siteID: sampleSiteID, productID: 100, productTypeKey: "variable") + ], + variations: [ + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 100, productVariationID: 500), + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 100, productVariationID: 501), + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 100, productVariationID: 502) + ], + syncDate: .now + ) + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // When + try await sut.deleteProducts([], variationIDs: [500, 502], siteID: sampleSiteID) + + // Then + try await db.read { db in + let variations = try PersistedProductVariation + .filter(sql: "\(PersistedProductVariation.Columns.siteID.name) = \(sampleSiteID)") + .fetchAll(db) + #expect(variations.count == 1) + #expect(variations.first?.id == 501) + } + } + + @Test func deleteProducts_removes_both_products_and_variations() async throws { + // Given + let catalog = POSCatalog( + products: [ + POSProduct.fake().copy(siteID: sampleSiteID, productID: 100), + POSProduct.fake().copy(siteID: sampleSiteID, productID: 200, productTypeKey: "variable") + ], + variations: [ + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 200, productVariationID: 500), + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 200, productVariationID: 501) + ], + syncDate: .now + ) + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // When - Delete one product and one variation + try await sut.deleteProducts([100], variationIDs: [500], siteID: sampleSiteID) + + // Then + try await db.read { db in + let products = try PersistedProduct + .filter(sql: "\(PersistedProduct.Columns.siteID.name) = \(sampleSiteID)") + .fetchAll(db) + #expect(products.count == 1) + #expect(products.first?.id == 200) + + let variations = try PersistedProductVariation + .filter(sql: "\(PersistedProductVariation.Columns.siteID.name) = \(sampleSiteID)") + .fetchAll(db) + #expect(variations.count == 1) + #expect(variations.first?.id == 501) + } + } + + @Test func deleteProducts_only_affects_specified_site() async throws { + // Given - Add products for two different sites + let site1Catalog = POSCatalog( + products: [POSProduct.fake().copy(siteID: 100, productID: 1)], + variations: [], + syncDate: .now + ) + let site2Catalog = POSCatalog( + products: [POSProduct.fake().copy(siteID: 200, productID: 1)], + variations: [], + syncDate: .now + ) + try await sut.replaceAllCatalogData(site1Catalog, siteID: 100) + try await sut.replaceAllCatalogData(site2Catalog, siteID: 200) + + // When - Delete from site 100 only + try await sut.deleteProducts([1], variationIDs: [], siteID: 100) + + // Then - Site 100 should have no products, site 200 should still have its product + try await db.read { db in + let site1Products = try PersistedProduct + .filter(sql: "\(PersistedProduct.Columns.siteID.name) = 100") + .fetchAll(db) + #expect(site1Products.isEmpty) + + let site2Products = try PersistedProduct + .filter(sql: "\(PersistedProduct.Columns.siteID.name) = 200") + .fetchAll(db) + #expect(site2Products.count == 1) + } + } + + @Test func deleteProducts_succeeds_when_product_not_found() async throws { + // Given + let catalog = POSCatalog( + products: [POSProduct.fake().copy(siteID: sampleSiteID, productID: 100)], + variations: [], + syncDate: .now + ) + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // When - Try to delete a product that doesn't exist + try await sut.deleteProducts([999], variationIDs: [], siteID: sampleSiteID) + + // Then - Should not throw, existing product should remain + try await db.read { db in + let products = try PersistedProduct + .filter(sql: "\(PersistedProduct.Columns.siteID.name) = \(sampleSiteID)") + .fetchAll(db) + #expect(products.count == 1) + #expect(products.first?.id == 100) + } + } } private extension POSCatalogPersistenceServiceTests { diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift index e7e09015375..5a4b4dd2a20 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing @testable import Yosemite +import enum Networking.DotcomError +import enum Networking.NetworkError +import enum Alamofire.AFError +import struct Networking.AnyDecodable struct POSOrderServiceTests { let sut: POSOrderService @@ -195,6 +199,315 @@ struct POSOrderServiceTests { return true }) } + + // MARK: - Missing Products Tests + + @Test func syncOrder_throws_error_when_order_is_missing_cart_products() async throws { + // Given + let cart = POSCart(items: [ + makePOSCartItem(productID: 100, quantity: 1), + makePOSCartItem(productID: 200, quantity: 2) + ]) + + // Mock returns an order with only one of the products + let orderWithMissingProduct = OrderFactory.newOrder(currency: .USD) + .copy( + siteID: 123, + status: .autoDraft, + items: [ + OrderItem.fake().copy(productID: 100, quantity: 1) + ] + ) + mockOrdersRemote.createPOSOrderResult = .success(orderWithMissingProduct) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + #expect(missingItems.count == 1) + #expect(missingItems.first?.productID == 200) + #expect(missingItems.first?.name == "") + return true + } + return false + }) + } + + @Test func syncOrder_succeeds_when_all_cart_items_in_order() async throws { + // Given + let cart = POSCart(items: [ + makePOSCartItem(productID: 100, quantity: 1), + makePOSCartItem(productID: 200, quantity: 2) + ]) + + // Mock returns an order with all cart items + let completeOrder = OrderFactory.newOrder(currency: .USD) + .copy( + siteID: 123, + status: .autoDraft, + items: [ + OrderItem.fake().copy(productID: 100, quantity: 1), + OrderItem.fake().copy(productID: 200, quantity: 2) + ] + ) + mockOrdersRemote.createPOSOrderResult = .success(completeOrder) + + // When/Then - Should not throw + _ = try await sut.syncOrder(cart: cart, currency: .USD) + } + + @Test func syncOrder_throws_error_when_order_missing_variation() async throws { + // Given + let cart = POSCart(items: [ + POSCartItem( + item: POSVariation( + id: UUID(), + name: "Large", + formattedPrice: "$20", + price: "20", + productID: 100, + variationID: 500, + parentProductName: "T-Shirt" + ), + quantity: 1 + ) + ]) + + // Mock returns an empty order + mockOrdersRemote.createPOSOrderResult = .success(OrderFactory.newOrder(currency: .USD)) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + #expect(missingItems.count == 1) + #expect(missingItems.first?.variationID == 500) + #expect(missingItems.first?.productID == 100) + return true + } + return false + }) + } + + @Test func syncOrder_distinguishes_between_variations_of_same_product() async throws { + // Given + let cart = POSCart(items: [ + POSCartItem( + item: POSVariation( + id: UUID(), + name: "Small", + formattedPrice: "$15", + price: "15", + productID: 100, + variationID: 500, + parentProductName: "T-Shirt" + ), + quantity: 1 + ), + POSCartItem( + item: POSVariation( + id: UUID(), + name: "Large", + formattedPrice: "$20", + price: "20", + productID: 100, + variationID: 501, + parentProductName: "T-Shirt" + ), + quantity: 1 + ) + ]) + + // Mock returns order with only one variation + let orderWithOneVariation = OrderFactory.newOrder(currency: .USD) + .copy( + siteID: 123, + status: .autoDraft, + items: [ + OrderItem.fake().copy(productID: 100, variationID: 500, quantity: 1) + ] + ) + mockOrdersRemote.createPOSOrderResult = .success(orderWithOneVariation) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + // Should only report the missing variation (variationID 501) + #expect(missingItems.count == 1) + #expect(missingItems.first?.variationID == 501) + #expect(missingItems.first?.productID == 100) + return true + } + return false + }) + } + + // MARK: - Server-side Validation Error Tests + + @Test func syncOrder_throws_missingProducts_error_for_DotcomError_with_invalid_variation_code() async throws { + // Given + let cart = POSCart(items: [makePOSCartItem(productID: 100, quantity: 1)]) + let dotcomError = DotcomError.unknown(code: "order_item_product_invalid_variation_id", message: "Invalid variation") + mockOrdersRemote.createPOSOrderResult = .failure(dotcomError) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + #expect(missingItems.count == 1) + #expect(missingItems.first?.productID == 0) // Generic error + #expect(missingItems.first?.variationID == 0) + #expect(missingItems.first?.name == "One or more products") + return true + } + return false + }) + } + + @Test func syncOrder_throws_missingProducts_error_for_NetworkError_with_invalid_variation_code_and_variation_id() async throws { + // Given + let cart = POSCart(items: [ + POSCartItem( + item: POSVariation( + id: UUID(), + name: "Large", + formattedPrice: "$20", + price: "20", + productID: 100, + variationID: 500, + parentProductName: "T-Shirt" + ), + quantity: 1 + ) + ]) + + let errorJSON = """ + { + "code": "order_item_product_invalid_variation_id", + "message": "Invalid variation", + "data": { + "variation_id": 500 + } + } + """ + let errorData = errorJSON.data(using: .utf8)! + let networkError = NetworkError.unacceptableStatusCode(statusCode: 400, response: errorData) + mockOrdersRemote.createPOSOrderResult = .failure(networkError) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + #expect(missingItems.count == 1) + #expect(missingItems.first?.productID == 100) + #expect(missingItems.first?.variationID == 500) + #expect(missingItems.first?.name == "T-Shirt - Large") + return true + } + return false + }) + } + + @Test func syncOrder_throws_missingProducts_error_for_NetworkError_without_variation_id() async throws { + // Given + let cart = POSCart(items: [makePOSCartItem(productID: 100, quantity: 1)]) + let errorJSON = """ + { + "code": "order_item_product_invalid_variation_id", + "message": "Invalid variation" + } + """ + let errorData = errorJSON.data(using: .utf8)! + let networkError = NetworkError.unacceptableStatusCode(statusCode: 400, response: errorData) + mockOrdersRemote.createPOSOrderResult = .failure(networkError) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + #expect(missingItems.count == 1) + #expect(missingItems.first?.productID == 0) // Generic error + #expect(missingItems.first?.variationID == 0) + return true + } + return false + }) + } + + @Test func syncOrder_throws_missingProducts_error_for_AFError_wrapping_DotcomError() async throws { + // Given + let cart = POSCart(items: [makePOSCartItem(productID: 100, quantity: 1)]) + let dotcomError = DotcomError.unknown(code: "order_item_product_invalid_variation_id", message: "Invalid") + let afError = AFError.sessionTaskFailed(error: dotcomError) + mockOrdersRemote.createPOSOrderResult = .failure(afError) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + #expect(missingItems.count == 1) + return true + } + return false + }) + } + + @Test func syncOrder_throws_original_error_for_unrecognized_DotcomError_code() async throws { + // Given + let cart = POSCart(items: [makePOSCartItem(productID: 100, quantity: 1)]) + let dotcomError = DotcomError.unknown(code: "some_other_error_code", message: "Different error") + mockOrdersRemote.createPOSOrderResult = .failure(dotcomError) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + // Should throw the original DotcomError, not missingProductsInOrder + if case .unknown = error as? DotcomError { + return true + } + return false + }) + } + + @Test func syncOrder_throws_missingProducts_with_generic_name_when_variation_not_in_cart() async throws { + // Given + let cart = POSCart(items: [makePOSCartItem(productID: 100, quantity: 1)]) + let errorJSON = """ + { + "code": "order_item_product_invalid_variation_id", + "message": "Invalid variation", + "data": { + "variation_id": 999 + } + } + """ + let errorData = errorJSON.data(using: .utf8)! + let networkError = NetworkError.unacceptableStatusCode(statusCode: 400, response: errorData) + mockOrdersRemote.createPOSOrderResult = .failure(networkError) + + // When/Then + await #expect(performing: { + try await sut.syncOrder(cart: cart, currency: .USD) + }, throws: { error in + if case .missingProductsInOrder(let missingItems) = error as? POSOrderService.POSOrderServiceError { + #expect(missingItems.count == 1) + #expect(missingItems.first?.productID == 0) + #expect(missingItems.first?.variationID == 999) + #expect(missingItems.first?.name == "One or more products") + return true + } + return false + }) + } } private func makePOSCartItem( diff --git a/WooCommerce/Classes/Analytics/TracksProvider.swift b/WooCommerce/Classes/Analytics/TracksProvider.swift index 9340dfc9164..b2845b75087 100644 --- a/WooCommerce/Classes/Analytics/TracksProvider.swift +++ b/WooCommerce/Classes/Analytics/TracksProvider.swift @@ -174,6 +174,9 @@ private extension TracksProvider { WooAnalyticsStat.pointOfSaleOrdersListSearchResultsFetched, WooAnalyticsStat.pointOfSaleOrderDetailsLoaded, WooAnalyticsStat.pointOfSaleOrderDetailsEmailReceiptTapped, + WooAnalyticsStat.pointOfSaleCheckoutOutdatedItemDetectedScreenShown, + WooAnalyticsStat.pointOfSaleCheckoutOutdatedItemDetectedEditOrderTapped, + WooAnalyticsStat.pointOfSaleCheckoutOutdatedItemDetectedRemoveTapped, // Order WooAnalyticsStat.ordersListLoaded, diff --git a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift index d234672d808..ceda01d2d0a 100644 --- a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift +++ b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift @@ -311,4 +311,8 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws { // Not used in these tests } + + func deleteProductsFromCatalog(_ productIDs: [Int64], variationIDs: [Int64], siteID: Int64) async throws { + // no-op + } }