diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index 873d7361fcc..8976c1cb80c 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -442,6 +442,7 @@ extension Networking.Customer { public static func fake() -> Networking.Customer { .init( siteID: .fake(), + userID: .fake(), customerID: .fake(), email: .fake(), username: .fake(), diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index b41103490e5..db0109b0200 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -637,6 +637,7 @@ extension Networking.CreateProductVariation { extension Networking.Customer { public func copy( siteID: CopiableProp = .copy, + userID: CopiableProp = .copy, customerID: CopiableProp = .copy, email: CopiableProp = .copy, username: NullableCopiableProp = .copy, @@ -646,6 +647,7 @@ extension Networking.Customer { shipping: NullableCopiableProp
= .copy ) -> Networking.Customer { let siteID = siteID ?? self.siteID + let userID = userID ?? self.userID let customerID = customerID ?? self.customerID let email = email ?? self.email let username = username ?? self.username @@ -656,6 +658,7 @@ extension Networking.Customer { return Networking.Customer( siteID: siteID, + userID: userID, customerID: customerID, email: email, username: username, diff --git a/Modules/Sources/Networking/Model/Customer.swift b/Modules/Sources/Networking/Model/Customer.swift index 059c8ed3adc..dedc6e5113d 100644 --- a/Modules/Sources/Networking/Model/Customer.swift +++ b/Modules/Sources/Networking/Model/Customer.swift @@ -4,11 +4,24 @@ import Codegen /// Represents a Customer entity: /// https://woocommerce.github.io/woocommerce-rest-api-docs/#customer-properties /// +/// This model is used in TWO different contexts: +/// 1. When fetching from `/wc/v3/customers/{id}` endpoint: +/// - `userID` = WordPress user ID (mapped from API "id" field) +/// - `customerID` = 0 (not available from this endpoint) +/// 2. When converting from Storage.Customer via toReadOnly(): +/// - `userID` = WordPress user ID (if registered) +/// - `customerID` = Analytics customer ID (from WCAnalyticsCustomer) +/// public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable { /// The siteID for the customer public let siteID: Int64 - /// Unique identifier for the customer + /// WordPress user ID (mapped from API "id" field) + /// This is the WordPress user account identifier, not the analytics customer ID + public let userID: Int64 + + /// Analytics customer ID (only set when converting from Storage.Customer taking value from WCAnalyticsCustomer) + /// This field is not mapped to any API response public let customerID: Int64 /// The email address for the customer @@ -31,13 +44,14 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable /// Computed property to check if the customer is a guest public var isGuest: Bool { - customerID == 0 + userID == 0 } /// Customer struct initializer /// public init(siteID: Int64, - customerID: Int64, + userID: Int64, + customerID: Int64 = 0, email: String, username: String?, firstName: String?, @@ -45,6 +59,7 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable billing: Address?, shipping: Address?) { self.siteID = siteID + self.userID = userID self.customerID = customerID self.email = email self.username = username @@ -63,7 +78,7 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable let container = try decoder.container(keyedBy: CodingKeys.self) - let customerID = try container.decode(Int64.self, forKey: .customerID) + let userID = try container.decode(Int64.self, forKey: .userID) let email = try container.decode(String.self, forKey: .email) let username = try container.decode(String.self, forKey: .username) let firstName = try container.decodeIfPresent(String.self, forKey: .firstName) @@ -72,7 +87,7 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable let shipping = try? container.decode(Address.self, forKey: .shipping) self.init(siteID: siteID, - customerID: customerID, + userID: userID, email: email, username: username, firstName: firstName, @@ -87,7 +102,7 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable /// extension Customer { enum CodingKeys: String, CodingKey { - case customerID = "id" + case userID = "id" case email case username case firstName = "first_name" diff --git a/Modules/Sources/Networking/Remote/CustomerRemote.swift b/Modules/Sources/Networking/Remote/CustomerRemote.swift index 275a5992a73..8bed62612dc 100644 --- a/Modules/Sources/Networking/Remote/CustomerRemote.swift +++ b/Modules/Sources/Networking/Remote/CustomerRemote.swift @@ -4,12 +4,12 @@ public class CustomerRemote: Remote { /// Retrieves a `Customer` /// /// - Parameters: - /// - customerID: ID of the customer that will be retrieved + /// - userID: ID of the registered WordPress user (customer) that will be retrieved. /// - siteID: Site for which we'll fetch the customer. /// - completion: Closure to be executed upon completion. /// - public func retrieveCustomer(for siteID: Int64, with customerID: Int64, completion: @escaping (Result) -> Void) { - let path = "customers/\(customerID)" + public func retrieveCustomer(for siteID: Int64, with userID: Int64, completion: @escaping (Result) -> Void) { + let path = "customers/\(userID)" let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, diff --git a/Modules/Sources/Storage/Model/Customer+CoreDataProperties.swift b/Modules/Sources/Storage/Model/Customer+CoreDataProperties.swift index cacfaea1ce2..b2a8f049213 100644 --- a/Modules/Sources/Storage/Model/Customer+CoreDataProperties.swift +++ b/Modules/Sources/Storage/Model/Customer+CoreDataProperties.swift @@ -20,6 +20,7 @@ extension Customer { @NSManaged public var billingPostcode: String? @NSManaged public var billingState: String? @NSManaged public var customerID: Int64 + @NSManaged public var userID: Int64 @NSManaged public var email: String? @NSManaged public var username: String? @NSManaged public var firstName: String? diff --git a/Modules/Sources/Storage/Model/MIGRATIONS.md b/Modules/Sources/Storage/Model/MIGRATIONS.md index 922e07d28d8..9b7b31147f7 100644 --- a/Modules/Sources/Storage/Model/MIGRATIONS.md +++ b/Modules/Sources/Storage/Model/MIGRATIONS.md @@ -2,6 +2,10 @@ This file documents changes in the WCiOS Storage data model. Please explain any changes to the data model as well as any custom migrations. +## Model 125 (Release 23.2.0.0) +- @povilasstaskus 2025-08-28 + - Added `userID` attribute to `Customer` entity to link customers with WordPress user accounts. + ## Model 124 (Release 22.9.0.0) - @itsmeichigo 2025-07-11 - Added `WooShippingShipment` entity. diff --git a/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/.xccurrentversion b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/.xccurrentversion index 3bd45fad8e3..a3ea46178c4 100644 --- a/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/.xccurrentversion +++ b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model 124.xcdatamodel + Model 125.xcdatamodel diff --git a/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 125.xcdatamodel/contents b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 125.xcdatamodel/contents new file mode 100644 index 00000000000..8c180ad10e7 --- /dev/null +++ b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 125.xcdatamodel/contents @@ -0,0 +1,1105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift index 0b3a9c92145..7480e106a4b 100644 --- a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift +++ b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift @@ -767,22 +767,22 @@ public extension StorageType { // MARK: - Customers - /// Returns a single Customer given a `siteID` and `customerID` - /// - func loadCustomer(siteID: Int64, customerID: Int64) -> Customer? { - let predicate = \Customer.siteID == siteID && \Customer.customerID == customerID - return firstObject(ofType: Customer.self, matching: predicate) - } - func loadAllCustomers(siteID: Int64) -> [Customer] { let predicate = \Customer.siteID == siteID return allObjects(ofType: Customer.self, matching: predicate, sortedBy: []) } + /// Returns stored Customers given a `siteID` matching `userIDs` + /// + func loadCustomers(siteID: Int64, matchingUserIDs userIDs: [Int64]) -> [Customer] { + let predicate = NSPredicate(format: "siteID == %lld && userID in %@", siteID, userIDs) + return allObjects(ofType: Customer.self, matching: predicate, sortedBy: []) + } + /// Returns stored Customers given a `siteID` matching `customerIDs` /// - func loadCustomers(siteID: Int64, matching customerIDs: [Int64]) -> [Customer] { - let predicate = NSPredicate(format: "siteID == %lld && customerID in %@", siteID, customerIDs) + func loadCustomers(siteID: Int64, matchingCustomerIDs userIDs: [Int64]) -> [Customer] { + let predicate = NSPredicate(format: "siteID == %lld && customerID in %@", siteID, userIDs) return allObjects(ofType: Customer.self, matching: predicate, sortedBy: []) } diff --git a/Modules/Sources/Yosemite/Actions/CustomerAction.swift b/Modules/Sources/Yosemite/Actions/CustomerAction.swift index 2d161fe10b9..55b390c1221 100644 --- a/Modules/Sources/Yosemite/Actions/CustomerAction.swift +++ b/Modules/Sources/Yosemite/Actions/CustomerAction.swift @@ -85,13 +85,14 @@ public enum CustomerAction: Action { /// Retrieves a single Customer from a site /// ///- `siteID`: The site for which customers should be fetched. - ///- `customerID`: ID of the Customer to be fetched. + ///- `customerID`: ID of the registered WordPress user (customer) that will be retrieved. + ///- `onCompletion`: Invoked when the operation finishes. /// - `result.success(Customer)`: The Customer object /// - `result.failure(Error)`: Error fetching Customer case retrieveCustomer( siteID: Int64, - customerID: Int64, + userID: Int64, onCompletion: (Result) -> Void) diff --git a/Modules/Sources/Yosemite/Model/Orders/CustomerFilter.swift b/Modules/Sources/Yosemite/Model/Orders/CustomerFilter.swift index 98332f1bd39..dd24611a75b 100644 --- a/Modules/Sources/Yosemite/Model/Orders/CustomerFilter.swift +++ b/Modules/Sources/Yosemite/Model/Orders/CustomerFilter.swift @@ -10,7 +10,7 @@ public struct CustomerFilter: Codable, Hashable { public let username: String? public init(customer: Customer) { - self.id = customer.customerID + self.id = customer.userID self.firstName = customer.firstName self.lastName = customer.lastName self.email = customer.email diff --git a/Modules/Sources/Yosemite/Model/Storage/Customer+ReadOnlyConvertible.swift b/Modules/Sources/Yosemite/Model/Storage/Customer+ReadOnlyConvertible.swift index f88718649bc..edc4d012750 100644 --- a/Modules/Sources/Yosemite/Model/Storage/Customer+ReadOnlyConvertible.swift +++ b/Modules/Sources/Yosemite/Model/Storage/Customer+ReadOnlyConvertible.swift @@ -1,17 +1,18 @@ import Foundation import Storage +/// Storage.Customer is an aggregate object that combines data from multiple sources: +/// 1. WCAnalyticsCustomer (analytics data) - contains customerID and userID (0 if WordPress user is not registered) +/// 2. Networking.Customer (WordPress user data) - contains userId (WordPress user ID) +/// +/// public protocol StorageCustomerConvertible { - var loadingID: Int64 { get } + var userID: Int64 { get } + var customerID: Int64 { get } } -extension Yosemite.Customer: StorageCustomerConvertible { - public var loadingID: Int64 { customerID } -} - -extension Yosemite.WCAnalyticsCustomer: StorageCustomerConvertible { - public var loadingID: Int64 { userID } -} +extension Yosemite.Customer: StorageCustomerConvertible {} +extension Yosemite.WCAnalyticsCustomer: StorageCustomerConvertible {} // MARK: - Storage.Customer: ReadOnlyConvertible // @@ -28,9 +29,11 @@ extension Storage.Customer: ReadOnlyConvertible { } /// Updates the `Storage.Customer` from the ReadOnly representation (`Networking.Customer`) + /// This is called when we have WordPress user customer data /// public func update(with customer: Yosemite.Customer) { - customerID = customer.customerID + // WordPress customers are registered users, so userID equals userId + userID = customer.userID siteID = customer.siteID email = customer.email firstName = customer.firstName @@ -65,7 +68,9 @@ extension Storage.Customer: ReadOnlyConvertible { /// Updates the `Storage.Customer` from the ReadOnly representation (`Yosemite.WCAnalyticsCustomer`) /// public func update(with customer: Yosemite.WCAnalyticsCustomer) { - customerID = customer.userID + customerID = customer.customerID + // Set userID to link with WordPress user account (if registered) + userID = customer.userID siteID = customer.siteID email = customer.email username = customer.username @@ -80,10 +85,18 @@ extension Storage.Customer: ReadOnlyConvertible { shippingFirstName = firstName shippingLastName = lastName shippingEmail = email + shippingCity = customer.city + shippingState = customer.region + shippingPostcode = customer.postcode + shippingCountry = customer.country billingFirstName = firstName billingLastName = lastName billingEmail = email + billingCity = customer.city + billingState = customer.region + billingPostcode = customer.postcode + billingCountry = customer.country } /// Returns a ReadOnly (`Networking.Customer`) version of the `Storage.Customer` @@ -91,7 +104,8 @@ extension Storage.Customer: ReadOnlyConvertible { public func toReadOnly() -> Yosemite.Customer { return Customer( siteID: siteID, - customerID: customerID, + userID: userID, // WordPress user ID (if registered) + customerID: customerID, // Analytics customer ID email: email ?? "", username: username ?? "", firstName: firstName ?? "", diff --git a/Modules/Sources/Yosemite/Stores/CustomerStore.swift b/Modules/Sources/Yosemite/Stores/CustomerStore.swift index 6225093c782..5f9e7911885 100644 --- a/Modules/Sources/Yosemite/Stores/CustomerStore.swift +++ b/Modules/Sources/Yosemite/Stores/CustomerStore.swift @@ -66,8 +66,8 @@ public final class CustomerStore: Store { onCompletion: onCompletion) case let .searchWCAnalyticsCustomers(siteID, pageNumber, pageSize, keyword, filter, onCompletion): searchWCAnalyticsCustomers(for: siteID, pageNumber: pageNumber, pageSize: pageSize, keyword: keyword, filter: filter, onCompletion: onCompletion) - case .retrieveCustomer(siteID: let siteID, customerID: let customerID, onCompletion: let onCompletion): - retrieveCustomer(for: siteID, with: customerID, onCompletion: onCompletion) + case .retrieveCustomer(siteID: let siteID, userID: let userID, onCompletion: let onCompletion): + retrieveCustomer(for: siteID, with: userID, onCompletion: onCompletion) case let .synchronizeLightCustomersData(siteID, pageNumber, pageSize, orderby, order, filterEmpty, onCompletion): synchronizeLightCustomersData(siteID: siteID, pageNumber: pageNumber, @@ -174,14 +174,14 @@ public final class CustomerStore: Store { /// /// - Parameters: /// - siteID: The site for which customers should be fetched. - /// - customerID: ID of the Customer to be fetched. + /// - customerID: ID of the registered WordPress user (customer) that will be retrieved. /// - onCompletion: Invoked when the operation finishes. Will upsert the Customer to Storage, or return an Error. /// func retrieveCustomer( for siteID: Int64, - with customerID: Int64, + with userID: Int64, onCompletion: @escaping (Result) -> Void) { - customerRemote.retrieveCustomer(for: siteID, with: customerID) { [weak self] result in + customerRemote.retrieveCustomer(for: siteID, with: userID) { [weak self] result in guard let self else { return } switch result { case .success(let customer): @@ -267,7 +267,7 @@ public final class CustomerStore: Store { let group = DispatchGroup() for result in searchResults { // At the moment, we're not searching through non-registered customers - // As we only search by customer ID, calls to /wc/v3/customers/0 will always fail + // As we only search by user ID, calls to /wc/v3/customers/0 will always fail // https://github.com/woocommerce/woocommerce-ios/issues/7741 if result.userID == 0 { continue @@ -282,7 +282,7 @@ public final class CustomerStore: Store { } group.notify(queue: .main) { - self.upsertSearchCustomerResult( + self.upsertRegisteredSearchCustomerResult( siteID: siteID, keyword: keyword, readOnlyCustomers: customers, @@ -297,11 +297,13 @@ public final class CustomerStore: Store { // MARK: Storage operations private extension CustomerStore { /// Inserts or updates CustomerSearchResults in Storage + /// Only used in CommandSearchUICommand when .betterCustomerSelectionInOrder feature flag is disabled + /// Likely could be removed /// - private func upsertSearchCustomerResult(siteID: Int64, - keyword: String, - readOnlyCustomers: [Networking.Customer], - onCompletion: @escaping () -> Void) { + private func upsertRegisteredSearchCustomerResult(siteID: Int64, + keyword: String, + readOnlyCustomers: [Networking.Customer], + onCompletion: @escaping () -> Void) { storageManager.performAndSave({ storage in let storedSearchResult = storage.loadCustomerSearchResult(siteID: siteID, keyword: keyword) ?? storage.insertNewObject(ofType: Storage.CustomerSearchResult.self) @@ -309,9 +311,9 @@ private extension CustomerStore { storedSearchResult.siteID = siteID storedSearchResult.keyword = keyword - let storedCustomers = storage.loadCustomers(siteID: siteID, matching: readOnlyCustomers.map { $0.customerID }) + let storedCustomers = storage.loadCustomers(siteID: siteID, matchingUserIDs: readOnlyCustomers.map { $0.userID }) for result in readOnlyCustomers { - if let storedCustomer = storedCustomers.first(where: { $0.customerID == result.customerID }) { + if let storedCustomer = storedCustomers.first(where: { $0.userID == result.userID }) { storedSearchResult.addToCustomers(storedCustomer) } } @@ -328,7 +330,7 @@ private extension CustomerStore { storage.deleteCustomers(siteID: siteID) } - let storedCustomers = storage.loadCustomers(siteID: siteID, matching: readOnlyCustomers.map { $0.loadingID }) + let storedCustomers = storage.loadCustomers(siteID: siteID, matchingCustomerIDs: readOnlyCustomers.map { $0.customerID }) let storedSearchResult: CustomerSearchResult? = { guard let keyword else { return nil @@ -395,11 +397,11 @@ private extension CustomerStore { storedSearchResult: Storage.CustomerSearchResult?, in storage: StorageType) { let storageCustomer: Storage.Customer = { - // If the specific customerID for that siteID already exists, return it - // If doesn't or the user is unregistered (loadingID == 0), insert a new one in Storage + // If the specific userId for that siteID already exists, return it + // If doesn't or the user is unregistered (userId == 0), insert a new one in Storage // Since we reset the customers everytime we request them, there's no risk of having duplicated unregistered customers - if readOnlyCustomer.loadingID != 0, - let storedCustomer = storedCustomers.first(where: { $0.customerID == readOnlyCustomer.loadingID }) { + if readOnlyCustomer.userID != 0, + let storedCustomer = storedCustomers.first(where: { $0.userID == readOnlyCustomer.userID }) { return storedCustomer } else { return storage.insertNewObject(ofType: Storage.Customer.self) diff --git a/Modules/Tests/NetworkingTests/Mapper/CustomerMapperTests.swift b/Modules/Tests/NetworkingTests/Mapper/CustomerMapperTests.swift index 5845db59eee..49503d43263 100644 --- a/Modules/Tests/NetworkingTests/Mapper/CustomerMapperTests.swift +++ b/Modules/Tests/NetworkingTests/Mapper/CustomerMapperTests.swift @@ -44,7 +44,8 @@ class CustomerMapperTests: XCTestCase { // Then XCTAssertNotNil(customer) - XCTAssertEqual(customer.customerID, 25) + XCTAssertEqual(customer.userID, 25) + XCTAssertEqual(customer.customerID, 0) XCTAssertEqual(customer.email, "john.doe@example.com") XCTAssertEqual(customer.firstName, "John") XCTAssertEqual(customer.lastName, "Doe") @@ -76,7 +77,8 @@ class CustomerMapperTests: XCTestCase { // Then XCTAssertNotNil(customer) - XCTAssertEqual(customer.customerID, 25) + XCTAssertEqual(customer.userID, 25) + XCTAssertEqual(customer.customerID, 0) XCTAssertEqual(customer.email, "john.doe@example.com") XCTAssertEqual(customer.firstName, "John") XCTAssertEqual(customer.lastName, "Doe") diff --git a/Modules/Tests/StorageTests/CoreData/MigrationTests.swift b/Modules/Tests/StorageTests/CoreData/MigrationTests.swift index 46295f3db18..640079c80b9 100644 --- a/Modules/Tests/StorageTests/CoreData/MigrationTests.swift +++ b/Modules/Tests/StorageTests/CoreData/MigrationTests.swift @@ -3468,6 +3468,70 @@ final class MigrationTests: XCTestCase { let urlValue = migratedObject.value(forKey: "addPaymentMethodURL") as? String XCTAssertEqual(urlValue, "https://example.com") } + + func test_migrating_from_124_to_125_adds_userID_to_Customer_entity() throws { + // Given + let sourceContainer = try startPersistentContainer("Model 124") + let sourceContext = sourceContainer.viewContext + + // Create a customer in the source model (Model 124) + let object = sourceContext.insert(entityName: "Customer", properties: [ + "siteID": 123, + "customerID": 456, + "email": "test@example.com", + "firstName": "John", + "lastName": "Doe", + "username": "johndoe" + ]) + + try sourceContext.save() + + XCTAssertEqual(try sourceContext.count(entityName: "Customer"), 1) + + // Verify the customer exists and has the expected properties + let sourceCustomer = try XCTUnwrap(sourceContext.allObjects(entityName: "Customer").first) + XCTAssertEqual(sourceCustomer.value(forKey: "siteID") as? Int64, 123) + XCTAssertEqual(sourceCustomer.value(forKey: "customerID") as? Int64, 456) + XCTAssertEqual(sourceCustomer.value(forKey: "email") as? String, "test@example.com") + + // Verify userID field doesn't exist in Model 124 + XCTAssertNil(object.entity.attributesByName["userID"], "Precondition. Attribute does not exist.") + + + // When + let targetContainer = try migrate(sourceContainer, to: "Model 125") + + // Then + let targetContext = targetContainer.viewContext + + XCTAssertEqual(try targetContext.count(entityName: "Customer"), 1) + + let migratedCustomer = try XCTUnwrap(targetContext.allObjects(entityName: "Customer").first) + + // Verify existing fields are preserved + XCTAssertEqual(migratedCustomer.value(forKey: "siteID") as? Int64, 123) + XCTAssertEqual(migratedCustomer.value(forKey: "customerID") as? Int64, 456) + XCTAssertEqual(migratedCustomer.value(forKey: "email") as? String, "test@example.com") + XCTAssertEqual(migratedCustomer.value(forKey: "firstName") as? String, "John") + XCTAssertEqual(migratedCustomer.value(forKey: "lastName") as? String, "Doe") + XCTAssertEqual(migratedCustomer.value(forKey: "username") as? String, "johndoe") + + // Verify new userID field exists and has default value + XCTAssertEqual(migratedCustomer.value(forKey: "userID") as? Int64, 0) + + // Verify we can set and retrieve the new userID field + migratedCustomer.setValue(789, forKey: "userID") + XCTAssertEqual(migratedCustomer.value(forKey: "userID") as? Int64, 789) + } + + + @discardableResult + func insertWooShippingOriginAddress(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "WooShippingOriginAddress", properties: [ + "siteID": 1, + "id": "test-address" + ]) + } } // MARK: - Persistent Store Setup and Migrations @@ -4384,12 +4448,4 @@ private extension MigrationTests { "subItems": ["sub_1", "sub_2"] ]) } - - @discardableResult - func insertWooShippingOriginAddress(to context: NSManagedObjectContext) -> NSManagedObject { - context.insert(entityName: "WooShippingOriginAddress", properties: [ - "siteID": 1, - "id": "test-address" - ]) - } } diff --git a/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift b/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift index 73986668f13..7b2a31667b3 100644 --- a/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift +++ b/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift @@ -175,41 +175,6 @@ final class StorageTypeExtensionsTests: XCTestCase { XCTAssertEqual(coupon, storedCoupon) } - func test_loadCustomer_by_siteID_and_customerID() throws { - // Given - let customerID: Int64 = 123 - let customer = storage.insertNewObject(ofType: Customer.self) - customer.siteID = sampleSiteID - customer.customerID = customerID - - // When - let storedCustomer = try XCTUnwrap(storage.loadCustomer(siteID: sampleSiteID, customerID: customerID)) - - // Then - XCTAssertEqual(customer, storedCustomer) - } - - func test_loadCustomers_by_siteID_and_customerIDs() { - // Given - let customer1 = storage.insertNewObject(ofType: Customer.self) - customer1.siteID = sampleSiteID - customer1.customerID = 1 - - let customer2 = storage.insertNewObject(ofType: Customer.self) - customer2.siteID = sampleSiteID - customer2.customerID = 2 - - let customer3 = storage.insertNewObject(ofType: Customer.self) - customer3.siteID = sampleSiteID - customer3.customerID = 3 - - // When - let results = storage.loadCustomers(siteID: sampleSiteID, matching: [1, 3]) - - // Then - XCTAssertEqual(Set(results), Set([customer1, customer3])) - } - func test_loadCustomerSearchResult_by_siteID_and_keyword() throws { // Given let keyword: String = "some keyword" diff --git a/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift index 47a302c6839..2ef48918b06 100644 --- a/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift @@ -13,7 +13,7 @@ final class CustomerStoreTests: XCTestCase { private var searchRemote: WCAnalyticsCustomerRemote! private var store: CustomerStore! private let dummySiteID: Int64 = 12345 - private let dummyCustomerID: Int64 = 25 + private let dummyUserID: Int64 = 25 private let dummyKeyword: String = "John" override func setUp() { @@ -39,7 +39,7 @@ final class CustomerStoreTests: XCTestCase { // When let result: Result = waitFor { promise in - let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, customerID: self.dummyCustomerID) { result in + let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, userID: self.dummyUserID) { result in promise(result) } self.store.onAction(action) @@ -48,7 +48,7 @@ final class CustomerStoreTests: XCTestCase { // Then XCTAssertTrue(result.isSuccess) let customer = try result.get() - XCTAssertEqual(customer.customerID, 25) + XCTAssertEqual(customer.userID, 25) XCTAssertEqual(customer.firstName, "John") XCTAssertEqual(customer.lastName, "Doe") XCTAssertEqual(customer.email, "john.doe@example.com") @@ -80,7 +80,7 @@ final class CustomerStoreTests: XCTestCase { // When let result: Result = waitFor { promise in - let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, customerID: self.dummyCustomerID) { result in + let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, userID: self.dummyUserID) { result in promise(result) } self.store.onAction(action) @@ -200,30 +200,6 @@ final class CustomerStoreTests: XCTestCase { XCTAssertTrue(storedCustomerSearchResults?.customers?.allSatisfy { $0.firstName?.contains(dummyKeyword) == true } ?? false ) } - func test_retrieveCustomer_upserts_the_returned_Customer() { - // Given - network.simulateResponse(requestUrlSuffix: "customers/25", filename: "customer") - XCTAssertEqual(viewStorage.countObjects(ofType: Storage.Customer.self), 0) - - // When - let result: Result = waitFor { promise in - let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, customerID: self.dummyCustomerID) { result in - promise(result) - } - self.store.onAction(action) - } - - // Then - XCTAssertTrue(result.isSuccess) - XCTAssertEqual(viewStorage.countObjects(ofType: Storage.Customer.self), 1) - - let storedCustomer = viewStorage.loadCustomer(siteID: dummySiteID, customerID: dummyCustomerID) - XCTAssertNotNil(storedCustomer) - XCTAssertEqual(storedCustomer?.siteID, dummySiteID) - XCTAssertEqual(storedCustomer?.customerID, dummyCustomerID) - XCTAssertEqual(storedCustomer?.firstName, "John") - } - func test_searchCustomers_returns_no_customers_when_customer_is_not_registered() throws { // Given network.simulateResponse(requestUrlSuffix: "customers", filename: "wc-analytics-customers") diff --git a/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailView.swift b/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailView.swift index be4a9dbde90..fa354847bef 100644 --- a/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailView.swift +++ b/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailView.swift @@ -311,6 +311,7 @@ private extension CustomerDetailView { #Preview("Customer") { CustomerDetailView(viewModel: CustomerDetailViewModel(siteID: 1, customerID: 0, + userID: 0, name: "Pat Smith", dateLastActive: "Jan 1, 2024", email: "patsmith@example.com", @@ -328,6 +329,7 @@ private extension CustomerDetailView { #Preview("Customer with Placeholders") { CustomerDetailView(viewModel: CustomerDetailViewModel(siteID: 1, customerID: 0, + userID: 0, name: "Guest", dateLastActive: "Jan 1, 2024", email: nil, diff --git a/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailViewModel.swift b/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailViewModel.swift index 4d44ebba752..210ee38b4d1 100644 --- a/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Customers/CustomerDetailViewModel.swift @@ -9,6 +9,7 @@ final class CustomerDetailViewModel: ObservableObject { private let storageManager: StorageManagerType private let siteID: Int64 private let customerID: Int64 + private let userID: Int64 /// Customer name let name: String @@ -101,6 +102,7 @@ final class CustomerDetailViewModel: ObservableObject { init(siteID: Int64, customerID: Int64, + userID: Int64, name: String?, dateLastActive: String?, email: String?, @@ -119,6 +121,7 @@ final class CustomerDetailViewModel: ObservableObject { self.storageManager = storageManager self.siteID = siteID self.customerID = customerID + self.userID = userID self.name = name ?? Localization.guestName self.dateLastActive = dateLastActive self.email = email @@ -141,7 +144,8 @@ final class CustomerDetailViewModel: ObservableObject { storageManager: StorageManagerType = ServiceLocator.storageManager) { let currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) self.init(siteID: customer.siteID, - customerID: customer.userID, + customerID: customer.customerID, + userID: customer.userID, name: customer.name?.nullifyIfEmptyOrWhitespace(), dateLastActive: customer.dateLastActive.map { DateFormatter.mediumLengthLocalizedDateFormatter.string(from: $0) }, email: customer.email?.nullifyIfEmptyOrWhitespace(), @@ -309,13 +313,13 @@ extension CustomerDetailViewModel { /// func syncCustomerAddressData() { // Only try to sync the address data for registered customers - guard customerID != 0 else { + guard userID != 0 else { return } // Don't show loading state if we already have customer billing or shipping data to display updateStateIfNeeded(to: .loading) - let action = CustomerAction.retrieveCustomer(siteID: siteID, customerID: customerID) { [weak self] result in + let action = CustomerAction.retrieveCustomer(siteID: siteID, userID: userID) { [weak self] result in guard let self else { return } switch result { case .success: diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModel.swift index 68ef00f5fca..bfb789bb55a 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModel.swift @@ -44,15 +44,15 @@ final class CustomerSelectorViewModel { /// Loads the whole customer information and calls the completion closures /// func onCustomerSelected(_ customer: Customer, onCompletion: @escaping (Result<(), Error>) -> Void) { - guard customer.customerID != 0 else { + guard customer.userID != 0 else { // The customer is not registered, we won't get any further information. Dismiss and return data onCustomerSelected(customer) onCompletion(.success(())) return } - // Get the full data about that customer - stores.dispatch(CustomerAction.retrieveCustomer(siteID: siteID, customerID: customer.customerID, onCompletion: { [weak self] result in + // Get the full data about that customer using WordPress user ID + stores.dispatch(CustomerAction.retrieveCustomer(siteID: siteID, userID: customer.userID, onCompletion: { [weak self] result in switch result { case .success(let customer): self?.onCustomerSelected(customer) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift index d94a43626e0..19c4e3a83b9 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift @@ -837,7 +837,9 @@ final class EditableOrderViewModel: ObservableObject { let input = Self.createAddressesInputIfPossible(billingAddress: customer.billing, shippingAddress: customer.shipping) // The customer ID needs to be set before the addresses, so that the customer ID doesn't get overridden by the API response (customer_id = 0 // by default) from updating the order's addresses remotely. - orderSynchronizer.setCustomerID.send(customer.customerID) + // Use WordPress user ID (userID) for order creation, as the WooCommerce API expects customer_id to be the user ID who owns the order + // For guest customers (userID = 0), the API will handle this correctly + orderSynchronizer.setCustomerID.send(customer.userID) orderSynchronizer.setAddresses.send(input) resetAddressForm() } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModelTests.swift index 91b583a7fd6..dcc6beeb491 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewModelTests.swift @@ -186,7 +186,7 @@ final class CustomerSelectorViewModelTests: XCTestCase { passedCustomer = customer } - let returnedCustomer = Customer.fake().copy(customerID: 23, lastName: "Testion") + let returnedCustomer = Customer.fake().copy(userID: 23, customerID: 23, lastName: "Testion") stores.whenReceivingAction(ofType: CustomerAction.self) { action in switch action { @@ -198,7 +198,7 @@ final class CustomerSelectorViewModelTests: XCTestCase { } // When - let registeredCustomer = Customer.fake().copy(customerID: 23) + let registeredCustomer = Customer.fake().copy(userID: 23, customerID: 23) var returnedResult: (Result<(), any Error>)? waitForExpectation { expectation in @@ -232,7 +232,7 @@ final class CustomerSelectorViewModelTests: XCTestCase { } // When - let registeredCustomer = Customer.fake().copy(customerID: 23) + let registeredCustomer = Customer.fake().copy(userID: 23, customerID: 23) var passedError: NSError? waitForExpectation { expectation in viewModel.onCustomerSelected(registeredCustomer) { result in diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Search/Customer/CustomerSearchUICommandTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Search/Customer/CustomerSearchUICommandTests.swift index 24972cbab14..6af5598ec69 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Search/Customer/CustomerSearchUICommandTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Search/Customer/CustomerSearchUICommandTests.swift @@ -40,6 +40,7 @@ final class CustomerSearchUICommandTests: XCTestCase { let command = CustomerSearchUICommand(siteID: sampleSiteID) { _ in } let customer = Customer( siteID: sampleSiteID, + userID: 0, customerID: 1, email: "john.w@email.com", username: "john",