diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index 3e6d15cc047..110932bf7b8 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -339,7 +339,8 @@ extension Networking.Booking { startDate: .fake(), statusKey: .fake(), localTimezone: .fake(), - currency: .fake() + currency: .fake(), + orderInfo: .fake() ) } } diff --git a/Modules/Sources/Networking/Model/Bookings/Booking.swift b/Modules/Sources/Networking/Model/Bookings/Booking.swift index 4902636f992..6f80a95c493 100644 --- a/Modules/Sources/Networking/Model/Bookings/Booking.swift +++ b/Modules/Sources/Networking/Model/Bookings/Booking.swift @@ -22,8 +22,9 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable { public let statusKey: String public let localTimezone: String public let currency: String - // periphery: ignore - to be used later + public let orderInfo: BookingOrderInfo? + public var bookingStatus: BookingStatus { return BookingStatus(rawValue: statusKey) ?? .unknown } @@ -47,7 +48,8 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable { startDate: Date, statusKey: String, localTimezone: String, - currency: String) { + currency: String, + orderInfo: BookingOrderInfo?) { self.siteID = siteID self.bookingID = bookingID self.allDay = allDay @@ -66,6 +68,7 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable { self.statusKey = statusKey self.localTimezone = localTimezone self.currency = currency + self.orderInfo = orderInfo } /// The public initializer for Booking. @@ -99,6 +102,7 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable { let statusKey = try container.decode(String.self, forKey: .statusKey) let localTimezone = try container.decode(String.self, forKey: .localTimezone) let currency = try container.decode(String.self, forKey: .currency) + let orderInfo: BookingOrderInfo? = nil // to be prefilled when synced self.init(siteID: siteID, bookingID: bookingID, @@ -117,7 +121,8 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable { startDate: startDate, statusKey: statusKey, localTimezone: localTimezone, - currency: currency) + currency: currency, + orderInfo: orderInfo) } public func encode(to encoder: Encoder) throws { diff --git a/Modules/Sources/Networking/Model/Bookings/BookingCustomerInfo.swift b/Modules/Sources/Networking/Model/Bookings/BookingCustomerInfo.swift new file mode 100644 index 00000000000..613793724ce --- /dev/null +++ b/Modules/Sources/Networking/Model/Bookings/BookingCustomerInfo.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct BookingCustomerInfo: Hashable { + public let billingAddress: Address + + public init(billingAddress: Address) { + self.billingAddress = billingAddress + } +} diff --git a/Modules/Sources/Networking/Model/Bookings/BookingOrderInfo.swift b/Modules/Sources/Networking/Model/Bookings/BookingOrderInfo.swift new file mode 100644 index 00000000000..002e32a8ca4 --- /dev/null +++ b/Modules/Sources/Networking/Model/Bookings/BookingOrderInfo.swift @@ -0,0 +1,19 @@ +// periphery:ignore:all +import Foundation + +public struct BookingOrderInfo: Hashable { + public let statusKey: String + public let paymentInfo: BookingPaymentInfo? + public let customerInfo: BookingCustomerInfo? + public let productInfo: BookingProductInfo? + + public init(statusKey: String, + paymentInfo: BookingPaymentInfo?, + customerInfo: BookingCustomerInfo?, + productInfo: BookingProductInfo?) { + self.statusKey = statusKey + self.paymentInfo = paymentInfo + self.customerInfo = customerInfo + self.productInfo = productInfo + } +} diff --git a/Modules/Sources/Networking/Model/Bookings/BookingPaymentInfo.swift b/Modules/Sources/Networking/Model/Bookings/BookingPaymentInfo.swift new file mode 100644 index 00000000000..914a943b810 --- /dev/null +++ b/Modules/Sources/Networking/Model/Bookings/BookingPaymentInfo.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct BookingPaymentInfo: Hashable { + public let paymentMethodID: String + public let paymentMethodTitle: String + public let subtotal: String + public let subtotalTax: String + public let total: String + public let totalTax: String + + public init(paymentMethodID: String, + paymentMethodTitle: String, + subtotal: String, + subtotalTax: String, + total: String, + totalTax: String) { + self.paymentMethodID = paymentMethodID + self.paymentMethodTitle = paymentMethodTitle + self.subtotal = subtotal + self.subtotalTax = subtotalTax + self.total = total + self.totalTax = totalTax + } +} diff --git a/Modules/Sources/Networking/Model/Bookings/BookingProductInfo.swift b/Modules/Sources/Networking/Model/Bookings/BookingProductInfo.swift new file mode 100644 index 00000000000..de9efa2c5a5 --- /dev/null +++ b/Modules/Sources/Networking/Model/Bookings/BookingProductInfo.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct BookingProductInfo: Hashable { + public let name: String + + public init(name: String) { + self.name = name + } +} diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index 22241726261..e2fae5200ec 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -447,7 +447,8 @@ extension Networking.Booking { startDate: CopiableProp = .copy, statusKey: CopiableProp = .copy, localTimezone: CopiableProp = .copy, - currency: CopiableProp = .copy + currency: CopiableProp = .copy, + orderInfo: NullableCopiableProp = .copy ) -> Networking.Booking { let siteID = siteID ?? self.siteID let bookingID = bookingID ?? self.bookingID @@ -467,6 +468,7 @@ extension Networking.Booking { let statusKey = statusKey ?? self.statusKey let localTimezone = localTimezone ?? self.localTimezone let currency = currency ?? self.currency + let orderInfo = orderInfo ?? self.orderInfo return Networking.Booking( siteID: siteID, @@ -486,7 +488,8 @@ extension Networking.Booking { startDate: startDate, statusKey: statusKey, localTimezone: localTimezone, - currency: currency + currency: currency, + orderInfo: orderInfo ) } } diff --git a/Modules/Sources/NetworkingCore/Model/Address.swift b/Modules/Sources/NetworkingCore/Model/Address.swift index faf9e1240f1..f0a8923631b 100644 --- a/Modules/Sources/NetworkingCore/Model/Address.swift +++ b/Modules/Sources/NetworkingCore/Model/Address.swift @@ -3,7 +3,7 @@ import Codegen /// Represents an Address Entity. /// -public struct Address: Codable, Sendable, GeneratedFakeable, GeneratedCopiable { +public struct Address: Codable, Sendable, GeneratedFakeable, GeneratedCopiable, Hashable { public let firstName: String public let lastName: String public let company: String? diff --git a/Modules/Sources/Yosemite/Model/Booking/Booking+ReadOnlyConvertible.swift b/Modules/Sources/Yosemite/Model/Booking/Booking+ReadOnlyConvertible.swift index e5f0ec16dd2..97312522737 100644 --- a/Modules/Sources/Yosemite/Model/Booking/Booking+ReadOnlyConvertible.swift +++ b/Modules/Sources/Yosemite/Model/Booking/Booking+ReadOnlyConvertible.swift @@ -1,7 +1,6 @@ import Foundation import Storage - // MARK: - Storage.Booking: ReadOnlyConvertible // extension Storage.Booking: ReadOnlyConvertible { @@ -49,6 +48,7 @@ extension Storage.Booking: ReadOnlyConvertible { startDate: startDate ?? Date(), statusKey: statusKey ?? "", localTimezone: localTimezone ?? "", - currency: currency ?? "USD") + currency: currency ?? "USD", + orderInfo: orderInfo?.toReadOnly()) } } diff --git a/Modules/Sources/Yosemite/Model/Booking/BookingCustomerInfo+ReadOnlyConvertible.swift b/Modules/Sources/Yosemite/Model/Booking/BookingCustomerInfo+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..868360a76f7 --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Booking/BookingCustomerInfo+ReadOnlyConvertible.swift @@ -0,0 +1,35 @@ +import Foundation +import Storage + +// MARK: - Storage.BookingCustomerInfo: ReadOnlyConvertible +// +extension Storage.BookingCustomerInfo: ReadOnlyConvertible { + public func update(with customerInfo: Yosemite.BookingCustomerInfo) { + billingAddress1 = customerInfo.billingAddress.address1 + billingAddress2 = customerInfo.billingAddress.address2 + billingCity = customerInfo.billingAddress.city + billingCompany = customerInfo.billingAddress.company + billingCountry = customerInfo.billingAddress.country + billingEmail = customerInfo.billingAddress.email + billingFirstName = customerInfo.billingAddress.firstName + billingLastName = customerInfo.billingAddress.lastName + billingPhone = customerInfo.billingAddress.phone + billingPostcode = customerInfo.billingAddress.postcode + billingState = customerInfo.billingAddress.state + } + + public func toReadOnly() -> Yosemite.BookingCustomerInfo { + let address = Yosemite.Address(firstName: billingFirstName ?? "", + lastName: billingLastName ?? "", + company: billingCompany, + address1: billingAddress1 ?? "", + address2: billingAddress2, + city: billingCity ?? "", + state: billingState ?? "", + postcode: billingPostcode ?? "", + country: billingCountry ?? "", + phone: billingPhone, + email: billingEmail) + return .init(billingAddress: address) + } +} diff --git a/Modules/Sources/Yosemite/Model/Booking/BookingOrderInfo+ReadOnlyConvertible.swift b/Modules/Sources/Yosemite/Model/Booking/BookingOrderInfo+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..df63f0138bb --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Booking/BookingOrderInfo+ReadOnlyConvertible.swift @@ -0,0 +1,18 @@ +import Foundation +import Storage + +// MARK: - Storage.BookingOrderInfo: ReadOnlyConvertible +// +extension Storage.BookingOrderInfo: ReadOnlyConvertible { + public func update(with orderInfo: Yosemite.BookingOrderInfo) { + statusKey = orderInfo.statusKey + // Relationships are handled in BookingStore + } + + public func toReadOnly() -> Yosemite.BookingOrderInfo { + return .init(statusKey: statusKey ?? "", + paymentInfo: paymentInfo?.toReadOnly(), + customerInfo: customerInfo?.toReadOnly(), + productInfo: productInfo?.toReadOnly()) + } +} diff --git a/Modules/Sources/Yosemite/Model/Booking/BookingPaymentInfo+ReadOnlyConvertible.swift b/Modules/Sources/Yosemite/Model/Booking/BookingPaymentInfo+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..af1b8d71e49 --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Booking/BookingPaymentInfo+ReadOnlyConvertible.swift @@ -0,0 +1,24 @@ +import Foundation +import Storage + +// MARK: - Storage.BookingPaymentInfo: ReadOnlyConvertible +// +extension Storage.BookingPaymentInfo: ReadOnlyConvertible { + public func update(with paymentInfo: Yosemite.BookingPaymentInfo) { + paymentMethodID = paymentInfo.paymentMethodID + paymentMethodTitle = paymentInfo.paymentMethodTitle + subtotal = paymentInfo.subtotal + subtotalTax = paymentInfo.subtotalTax + total = paymentInfo.total + totalTax = paymentInfo.totalTax + } + + public func toReadOnly() -> Yosemite.BookingPaymentInfo { + return .init(paymentMethodID: paymentMethodID ?? "", + paymentMethodTitle: paymentMethodTitle ?? "", + subtotal: subtotal ?? "", + subtotalTax: subtotalTax ?? "", + total: total ?? "", + totalTax: totalTax ?? "") + } +} diff --git a/Modules/Sources/Yosemite/Model/Booking/BookingProductInfo+ReadOnlyConvertible.swift b/Modules/Sources/Yosemite/Model/Booking/BookingProductInfo+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..164d28ca596 --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Booking/BookingProductInfo+ReadOnlyConvertible.swift @@ -0,0 +1,14 @@ +import Foundation +import Storage + +// MARK: - Storage.BookingProductInfo: ReadOnlyConvertible +// +extension Storage.BookingProductInfo: ReadOnlyConvertible { + public func update(with productInfo: Yosemite.BookingProductInfo) { + name = productInfo.name + } + + public func toReadOnly() -> Yosemite.BookingProductInfo { + return .init(name: name ?? "") + } +} diff --git a/Modules/Sources/Yosemite/Model/Model.swift b/Modules/Sources/Yosemite/Model/Model.swift index e6288ab066c..ab9bd6b2e50 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -26,6 +26,10 @@ public typealias BlazeTargetOptions = Networking.BlazeTargetOptions public typealias BlazeTargetLocation = Networking.BlazeTargetLocation public typealias BlazeTargetTopic = Networking.BlazeTargetTopic public typealias Booking = Networking.Booking +public typealias BookingOrderInfo = Networking.BookingOrderInfo +public typealias BookingCustomerInfo = Networking.BookingCustomerInfo +public typealias BookingPaymentInfo = Networking.BookingPaymentInfo +public typealias BookingProductInfo = Networking.BookingProductInfo public typealias CreateBlazeCampaign = Networking.CreateBlazeCampaign public typealias FallibleCancelable = Hardware.FallibleCancelable public typealias CommentStatus = Networking.CommentStatus diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 5c31cc97bdf..d966d6d60f3 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -257,7 +257,7 @@ private extension BookingStore { /// - readOnlyOrders: Remote Orders associated with bookings. /// - storage: Where we should save all the things! /// - func upsertStoredBookings(readOnlyBookings: [Networking.Booking], readOnlyOrders: [Yosemite.Order], in storage: StorageType) { + func upsertStoredBookings(readOnlyBookings: [Yosemite.Booking], readOnlyOrders: [Yosemite.Order], in storage: StorageType) { // Fetch all existing bookings for the site at once let bookingIDs = readOnlyBookings.map { $0.bookingID } let siteID = readOnlyBookings.first?.siteID ?? 0 @@ -268,12 +268,36 @@ private extension BookingStore { let storageBooking = storedBookings.first { $0.bookingID == readOnlyBooking.bookingID } ?? storage.insertNewObject(ofType: StorageBooking.self) - // TODO: - Apply new Booking specific models if let associatedOrder = readOnlyOrders.first(where: { $0.orderID == readOnlyBooking.orderID }) { - /// 1. Convert `Order` into `Booking` specific order, product and customer - /// 2. Obtain corresponding associated `Storage` models from `storageBooking` or create new ones. - /// 3. Update the above models with values from `associatedOrder` - print("The order for the booking \(readOnlyBooking.bookingID): \(associatedOrder)") + let orderInfo = storageBooking.orderInfo ?? storage.insertNewObject(ofType: Storage.BookingOrderInfo.self) + + let productInfo = orderInfo.productInfo ?? storage.insertNewObject(ofType: Storage.BookingProductInfo.self) + let productName = associatedOrder.items.first(where: { $0.productID == readOnlyBooking.productID })?.name + productInfo.update(with: .init(name: productName ?? "")) + orderInfo.productInfo = productInfo + + if let billingAddress = associatedOrder.billingAddress { + let customerInfo = orderInfo.customerInfo ?? storage.insertNewObject(ofType: Storage.BookingCustomerInfo.self) + customerInfo.update(with: .init(billingAddress: billingAddress)) + orderInfo.customerInfo = customerInfo + } + + let paymentInfo = orderInfo.paymentInfo ?? storage.insertNewObject(ofType: Storage.BookingPaymentInfo.self) + paymentInfo.update(with: + BookingPaymentInfo( + paymentMethodID: associatedOrder.paymentMethodID, + paymentMethodTitle: associatedOrder.paymentMethodTitle, + subtotal: associatedOrder.items.map({ Double($0.subtotal) ?? 0 }).reduce(0, +).description, + subtotalTax: associatedOrder.items.map({ Double($0.subtotalTax) ?? 0 }).reduce(0, +).description, + total: associatedOrder.total, + totalTax: associatedOrder.totalTax + ) + ) + + orderInfo.paymentInfo = paymentInfo + + orderInfo.statusKey = associatedOrder.status.rawValue + storageBooking.orderInfo = orderInfo } storageBooking.update(with: readOnlyBooking) diff --git a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift index b325b51ac31..5ba53606229 100644 --- a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift @@ -534,6 +534,135 @@ struct BookingStoreTests { #expect(result.isSuccess) #expect(storedBookingCount == 0) } + + // MARK: - orderInfo Storage Tests + + @Test func synchronizeBookings_stores_complete_orderInfo_with_all_nested_properties() async throws { + // Given + let productID: Int64 = 100 + let booking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123, orderID: 1, productID: productID) + remote.whenLoadingAllBookings(thenReturn: .success([booking])) + + let billingAddress = Address.fake().copy( + firstName: "Jane", + lastName: "Smith", + address1: "456 Oak Ave", + city: "Los Angeles", + postcode: "90001", + country: "US" + ) + let orderItem = OrderItem.fake().copy( + itemID: 1, + name: "Premium Booking", + productID: productID, + subtotal: "200.00", + subtotalTax: "20.00" + ) + let order = Order.fake().copy( + orderID: 1, + status: .processing, + total: "220.00", + totalTax: "20.00", + paymentMethodID: "paypal", + paymentMethodTitle: "PayPal", + items: [orderItem], + billingAddress: billingAddress + ) + ordersRemote.whenLoadingOrders(thenReturn: .success([order])) + + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeBookings(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isSuccess) + let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 123)) + let orderInfo = try #require(storedBooking.orderInfo) + + // Verify order status + #expect(orderInfo.statusKey == "processing") + + // Verify product info + let productInfo = try #require(orderInfo.productInfo) + #expect(productInfo.name == "Premium Booking") + + // Verify customer info + let customerInfo = try #require(orderInfo.customerInfo) + #expect(customerInfo.billingFirstName == "Jane") + #expect(customerInfo.billingLastName == "Smith") + #expect(customerInfo.billingAddress1 == "456 Oak Ave") + #expect(customerInfo.billingCity == "Los Angeles") + + // Verify payment info + let paymentInfo = try #require(orderInfo.paymentInfo) + #expect(paymentInfo.paymentMethodID == "paypal") + #expect(paymentInfo.paymentMethodTitle == "PayPal") + #expect(paymentInfo.subtotal == "200.0") + #expect(paymentInfo.subtotalTax == "20.0") + #expect(paymentInfo.total == "220.00") + #expect(paymentInfo.totalTax == "20.00") + } + + @Test func synchronizeBooking_stores_orderInfo_correctly() async throws { + // Given + let productID: Int64 = 100 + let booking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123, orderID: 1, productID: productID) + remote.whenLoadingBooking(thenReturn: .success(booking)) + + let billingAddress = Address.fake().copy(firstName: "Test", lastName: "User") + let orderItem = OrderItem.fake().copy(itemID: 1, name: "Test Product", productID: productID) + let order = Order.fake().copy( + orderID: 1, + status: .onHold, + paymentMethodID: "cod", + items: [orderItem], + billingAddress: billingAddress + ) + ordersRemote.whenLoadingOrders(thenReturn: .success([order])) + + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction( + BookingAction.synchronizeBooking( + siteID: sampleSiteID, + bookingID: 123 + ) { result in + continuation.resume(returning: result) + } + ) + } + + // Then + #expect(result.isSuccess) + let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 123)) + let orderInfo = try #require(storedBooking.orderInfo) + #expect(orderInfo.statusKey == "on-hold") + + let productInfo = try #require(orderInfo.productInfo) + #expect(productInfo.name == "Test Product") + + let customerInfo = try #require(orderInfo.customerInfo) + #expect(customerInfo.billingFirstName == "Test") + #expect(customerInfo.billingLastName == "User") + } } private extension BookingStoreTests { diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index adbf587f42d..4d98e16295e 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -330,7 +330,8 @@ struct BookingDetailsView_Previews: PreviewProvider { startDate: now, statusKey: "paid", localTimezone: "America/New_York", - currency: "USD" + currency: "USD", + orderInfo: nil ) let viewModel = BookingDetailsViewModel(booking: sampleBooking) return BookingDetailsView(viewModel)