Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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
Expand Down Expand Up @@ -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
]
)
}
}
}

Expand Down
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 @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
54 changes: 54 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,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<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 {
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
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions Modules/Sources/PointOfSale/Models/PointOfSaleOrderState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@ 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) {
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 Expand Up @@ -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<Int64>, variationIDs: Set<Int64>) {
var productIDs = Set<Int64>()
var variationIDs = Set<Int64>()

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