Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d17a002
Show error when product does not exist in order
joshheald Nov 12, 2025
b656e81
Remove unverified WooCommerce error codes
joshheald Nov 12, 2025
84b69d8
Fix bug where removing one variation would remove all variations of s…
joshheald Nov 12, 2025
be58f92
Remove missing products from local catalog
joshheald Nov 12, 2025
486bce2
Add comprehensive tests for missing products handling
joshheald Nov 12, 2025
4e0d7f9
Fix: Use product/variation IDs instead of UUIDs for missing products
joshheald Nov 12, 2025
a48aac0
Enhance missing product error handling with variation ID extraction
joshheald Nov 13, 2025
21822f7
Auto-remove unavailable products from catalog when identified
joshheald Nov 13, 2025
6f269d7
Address code review feedback
joshheald Nov 13, 2025
4e6c1bf
Remove unused removeMissingProductsFromCatalog method
joshheald Nov 13, 2025
522f3d9
Add periphery ignore comments for public API properties
joshheald Nov 13, 2025
195b14b
Fix line length lint violation
joshheald Nov 13, 2025
a83c22a
Simplify periphery ignore comments
joshheald Nov 13, 2025
6e011b5
Fix periphery ignore comment placement
joshheald Nov 13, 2025
c5a4e9c
Add explanatory comments to periphery ignore directives
joshheald Nov 13, 2025
980d20d
Add analytics events for checkout outdated item detection
joshheald Nov 13, 2025
433859f
Fix syntax error in analytics event functions
joshheald Nov 13, 2025
efd7f33
Make PointOfSaleOrderSyncMissingProductsErrorMessageView public
joshheald Nov 13, 2025
8fc3bd0
Add reason key to analytics Key enum
joshheald Nov 13, 2025
2666068
Revert view to internal visibility
joshheald Nov 13, 2025
bcd7dc5
Remove unused quantity property from MissingProductInfo
joshheald Nov 13, 2025
a44e7ab
Extract post-sync cleanup into dedicated method
joshheald Nov 13, 2025
f8f17bf
Extract ID extraction logic and improve naming
joshheald Nov 13, 2025
4d3edb4
Remove unused property
joshheald Nov 14, 2025
836c617
Fix test
joshheald Nov 14, 2025
88bd0b1
Add clarifying comment for UUID in cart item grouping
joshheald Nov 18, 2025
e721dc8
Move networking error extraction to POSOrderService
joshheald Nov 18, 2025
ddfccf5
Remove networking error extraction from PointOfSaleOrderController
joshheald Nov 18, 2025
3c3df4c
Add tests for server-side validation error extraction
joshheald Nov 18, 2025
d19ab02
Improve missing product error messages
joshheald Nov 18, 2025
8b4c0c2
Fix string mismatch in unknown product error handling
joshheald Nov 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Modules/Sources/NetworkingCore/Network/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,24 @@ 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 {
return nil
}
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
}
}


Expand Down Expand Up @@ -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)
Expand All @@ -144,12 +155,14 @@ struct NetworkErrorResponse: Decodable {
}
return try container.decodeIfPresent(String.self, forKey: .code)
}()
self.data = try container.decodeIfPresent([String: AnyDecodable].self, forKey: .data)
}

/// Coding Keys
///
private enum CodingKeys: String, CodingKey {
case error
case code
case data
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -21,6 +23,9 @@ import class Yosemite.PluginsService
import enum WooFoundation.CurrencyCode
import protocol WooFoundation.Analytics
import enum Alamofire.AFError
import enum Networking.DotcomError
import enum Networking.NetworkError
import struct Networking.AnyDecodable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider if these 3 should be imported here, perhaps we could delegate the bits that need it in extractMissingProductsFromServerError to Yosemite or Networking.

import class Yosemite.OrderTotalsCalculator
import struct WooFoundation.WooAnalyticsEvent
import protocol WooFoundationCore.WooAnalyticsEventPropertyType
Expand Down Expand Up @@ -94,15 +99,16 @@ protocol PointOfSaleOrderControllerProtocol {
return .success(.newOrder)
} catch {
self.order = nil
trackOrderCreationFailed(error: error)
setOrderStateToError(error, retryHandler: retryHandler)
trackOrderCreationFailed(error: error, cart: posCart)
setOrderStateToError(error, cart: posCart, retryHandler: retryHandler)
return .failure(SyncOrderStateError.syncFailure)
}
}

private func setOrderStateToError(_ error: Error,
cart: POSCart,
retryHandler: @escaping () async -> Void) {
orderState = .error(orderStateError(from: error), {
orderState = .error(orderStateError(from: error, cart: cart), {
Task {
await retryHandler()
}
Expand Down Expand Up @@ -187,15 +193,150 @@ private extension PointOfSaleOrderController {
// MARK: - Error Handling

private extension PointOfSaleOrderController {
func orderStateError(from error: Error) -> PointOfSaleOrderState.OrderStateError {
if let couponsError = CouponsError(underlyingError: error) {
func orderStateError(from error: Error, cart: POSCart) -> PointOfSaleOrderState.OrderStateError {
// 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,
quantity: $0.expectedQuantity
)
}
return .missingProducts(missingProductInfo)
}
// Check for server-side validation errors about invalid products/variations
else if let missingProductInfo = extractMissingProductsFromServerError(error, cart: cart) {
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)
}
}

/// 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) -> [PointOfSaleOrderState.OrderStateError.MissingProductInfo]? {
// 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
private 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 MissingProductInfo for a specific variation ID
/// Searches the cart to find the variation's name and parent product ID
private func createMissingProductInfo(forVariationID variationID: Int64, cart: POSCart) -> [PointOfSaleOrderState.OrderStateError.MissingProductInfo] {
// 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 [
PointOfSaleOrderState.OrderStateError.MissingProductInfo(
productID: parentProductID,
variationID: variationID,
name: productName,
quantity: 1
)
]
}

/// Checks if an error code indicates a product validation error
/// Currently only handles the confirmed error code from WooCommerce server responses
private 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
private func extractMissingProductsFromCart() -> [PointOfSaleOrderState.OrderStateError.MissingProductInfo]? {
// 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 [
PointOfSaleOrderState.OrderStateError.MissingProductInfo(
productID: 0,
variationID: 0,
name: Localization.unknownProductName,
quantity: 1
)
]
}
}

private extension PointOfSaleOrderController {
enum Localization {
static let unknownProductName = NSLocalizedString(
"pointOfSale.orderController.unknownProduct",
value: "One or more products",
comment: "Generic product name used when we can't identify which specific product is unavailable"
)
}
}


Expand Down Expand Up @@ -256,11 +397,15 @@ extension PointOfSaleOrderController {


private extension PointOfSaleOrderController {
func trackOrderCreationFailed(error: Error) {
func trackOrderCreationFailed(error: Error, cart: POSCart) {
var errorType: WooAnalyticsEvent.Orders.OrderCreationErrorType?

if let _ = CouponsError(underlyingError: error) {
errorType = .invalidCoupon
} else if case .missingProductsInOrder = error as? POSOrderService.POSOrderServiceError {
errorType = .missingProducts
} else if extractMissingProductsFromServerError(error, cart: cart) != nil {
errorType = .missingProducts
}

analytics.track(event: WooAnalyticsEvent.Orders.orderCreationFailed(
Expand Down Expand Up @@ -290,9 +435,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(
Expand Down
62 changes: 62 additions & 0 deletions Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -193,6 +195,60 @@ 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<Int64>, variationIDs: Set<Int64>) {
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 {
// Extract product and variation IDs from missing products
var productIDs = Set<Int64>()
var variationIDs = Set<Int64>()

for missingProduct in missingProducts {
// Only process items where we can identify specific products (not 0,0)
if missingProduct.variationID != 0 {
variationIDs.insert(missingProduct.variationID)
} 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)
}

// 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
Expand Down Expand Up @@ -619,6 +675,12 @@ extension PointOfSaleAggregateModel {
await self?.checkOut()
})
trackOrderSyncState(syncOrderResult)

// If we identified specific missing products, remove them from the catalog immediately
if case .error(.missingProducts(let missingProducts), _) = orderController.orderState.externalState {
await removeIdentifiedMissingProductsFromCatalog(missingProducts)
}

await startPaymentWhenCardReaderConnected()
}
}
Expand Down
10 changes: 10 additions & 0 deletions Modules/Sources/PointOfSale/Models/PointOfSaleOrderState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,23 @@ 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
let quantity: Decimal // periphery:ignore - Part of public API for future use
}

static func == (lhs: OrderStateError, rhs: OrderStateError) -> Bool {
switch (lhs, rhs) {
case (.other(let lhsError), .other(let rhsError)):
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
}
Expand Down
Loading