From cc339f2304fe98aa28f8deac64fea48d56208107 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 09:50:25 +0300 Subject: [PATCH 01/25] Add Email receipt action with action section to point of sale order details --- .../Orders/PointOfSaleOrderDetailsView.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index 5dec5225a25..7a6a478a31b 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -26,6 +26,8 @@ struct PointOfSaleOrderDetailsView: View { ScrollView { VStack(alignment: .leading, spacing: POSSpacing.medium) { + actionsSection() + if !order.lineItems.isEmpty { productsSection(order) } @@ -278,6 +280,21 @@ private extension PointOfSaleOrderDetailsView { } } +// MARK: - Actions + +private extension PointOfSaleOrderDetailsView { + @ViewBuilder + func actionsSection() -> some View { + HStack { + Button(action: {}) { + Text(Localization.emailReceiptActionTitle) + } + .buttonStyle(POSOutlinedButtonStyle(size: .extraSmall)) + } + .padding(.vertical) + } +} + // MARK: - Localization private enum Localization { @@ -361,6 +378,12 @@ private enum Localization { value: "Net Payment", comment: "Label for net payment amount after refunds" ) + + static let emailReceiptActionTitle = NSLocalizedString( + "pos.orderDetailsView.emailReceiptAction.title", + value: "Email receipt", + comment: "Label for email receipt action on order details view" + ) } #if DEBUG From 1bf439911c94e446c9c88f4985a83e439f9d6eeb Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:05:32 +0300 Subject: [PATCH 02/25] Conditional actions presentation --- .../Orders/PointOfSaleOrderDetailsView.swift | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index 7a6a478a31b..7f159b2aa28 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -26,7 +26,9 @@ struct PointOfSaleOrderDetailsView: View { ScrollView { VStack(alignment: .leading, spacing: POSSpacing.medium) { - actionsSection() + if actions.isNotEmpty { + actionsSection(actions) + } if !order.lineItems.isEmpty { productsSection(order) @@ -283,13 +285,39 @@ private extension PointOfSaleOrderDetailsView { // MARK: - Actions private extension PointOfSaleOrderDetailsView { + enum POSOrderDetailsAction: Identifiable, CaseIterable { + case emailReceipt + + var id: String { title } + + var title: String { + switch self { + case .emailReceipt: + Localization.emailReceiptActionTitle + } + } + + func available(for order: POSOrder) -> Bool { + switch self { + case .emailReceipt: + order.status == .completed + } + } + } + + var actions: [POSOrderDetailsAction] { + POSOrderDetailsAction.allCases.filter { $0.available(for: order) } + } + @ViewBuilder - func actionsSection() -> some View { + func actionsSection(_ actions: [POSOrderDetailsAction]) -> some View { HStack { - Button(action: {}) { - Text(Localization.emailReceiptActionTitle) + ForEach(actions) { action in + Button(action: {}) { + Text(action.title) + } + .buttonStyle(POSOutlinedButtonStyle(size: .extraSmall)) } - .buttonStyle(POSOutlinedButtonStyle(size: .extraSmall)) } .padding(.vertical) } From e4e58e6d6ed13b7ee1695af1f801500899ab7b3c Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:13:54 +0300 Subject: [PATCH 03/25] Extract receipt related logic to POSReceiptController PointOfSaleOrderController only works with current order that is currently being build. However, we want to reuse receipt functionality for historical orders as well. Extracting POSReceiptController to a separate entity allows to reuse it. --- .../Controllers/POSReceiptController.swift | 88 +++++++++++++++++++ .../PointOfSaleOrderController.swift | 71 ++------------- .../WooCommerce.xcodeproj/project.pbxproj | 12 +++ 3 files changed, 107 insertions(+), 64 deletions(-) create mode 100644 WooCommerce/Classes/POS/Controllers/POSReceiptController.swift diff --git a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift new file mode 100644 index 00000000000..6369579c6ef --- /dev/null +++ b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift @@ -0,0 +1,88 @@ +import Foundation +import Observation +import protocol Experiments.FeatureFlagService +import protocol Yosemite.StoresManager +import protocol Yosemite.POSOrderServiceProtocol +import protocol Yosemite.POSReceiptServiceProtocol +import protocol Yosemite.PluginsServiceProtocol +import struct Yosemite.Order +import enum Yosemite.Plugin +import protocol WooFoundation.Analytics +import class Yosemite.PluginsService + +protocol POSReceiptControllerProtocol { + func sendReceipt(order: Order, recipientEmail: String) async throws +} + +final class POSReceiptController: POSReceiptControllerProtocol { + init(orderService: POSOrderServiceProtocol, + receiptService: POSReceiptServiceProtocol, + analytics: Analytics = ServiceLocator.analytics, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager)) { + self.orderService = orderService + self.receiptService = receiptService + self.analytics = analytics + self.featureFlagService = featureFlagService + self.pluginsService = pluginsService + } + + private let orderService: POSOrderServiceProtocol + private let receiptService: POSReceiptServiceProtocol + private let analytics: Analytics + private let featureFlagService: FeatureFlagService + private let pluginsService: PluginsServiceProtocol + + @MainActor + func sendReceipt(order: Order, recipientEmail: String) async throws { + var isEligibleForPOSReceipt: Bool? + do { + try await orderService.updatePOSOrder(order: order, recipientEmail: recipientEmail) + + let posReceiptEligibility: Bool + if featureFlagService.isFeatureFlagEnabled(.pointOfSaleReceipts) { + posReceiptEligibility = isPluginSupported( + .wooCommerce, + minimumVersion: POSReceiptEligibilityConstants.wcPluginMinimumVersion, + siteID: order.siteID + ) + } else { + posReceiptEligibility = false + } + isEligibleForPOSReceipt = posReceiptEligibility + + try await receiptService.sendReceipt(order: order, recipientEmail: recipientEmail, isEligibleForPOSReceipt: posReceiptEligibility) + + analytics.track(.receiptEmailSuccess, withProperties: ["eligible_for_pos_receipt": posReceiptEligibility]) + } catch { + let properties = [ + "eligible_for_pos_receipt": isEligibleForPOSReceipt + ].compactMapValues( { $0 }) + analytics.track(.receiptEmailFailed, properties: properties, error: error) + throw error + } + } +} + +private extension POSReceiptController { + @MainActor + func isPluginSupported(_ plugin: Plugin, minimumVersion: String, siteID: Int64) -> Bool { + // Plugin must be installed and active + guard let systemPlugin = pluginsService.loadPluginInStorage(siteID: siteID, plugin: plugin, isActive: true), + systemPlugin.active else { + return false + } + + // If plugin version is higher than minimum required version, mark as eligible + let isSupported = VersionHelpers.isVersionSupported(version: systemPlugin.version, + minimumRequired: minimumVersion, + includesDevAndBetaVersions: true) + return isSupported + } +} + +private extension POSReceiptController { + enum POSReceiptEligibilityConstants { + static let wcPluginMinimumVersion = "10.0.0" + } +} diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index 091d09a5476..630af9b56ff 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -1,10 +1,7 @@ import Foundation import Observation -import protocol Experiments.FeatureFlagService import protocol Yosemite.StoresManager import protocol Yosemite.POSOrderServiceProtocol -import protocol Yosemite.POSReceiptServiceProtocol -import protocol Yosemite.PluginsServiceProtocol import struct Yosemite.Order import struct Yosemite.POSCart import struct Yosemite.POSCartItem @@ -12,10 +9,8 @@ import struct Yosemite.POSCoupon import struct Yosemite.CouponsError import enum Yosemite.OrderAction import enum Yosemite.OrderUpdateField -import enum Yosemite.Plugin import class WooFoundation.CurrencyFormatter import class WooFoundation.CurrencySettings -import class Yosemite.PluginsService import enum WooFoundation.CurrencyCode import protocol WooFoundation.Analytics import enum Alamofire.AFError @@ -41,34 +36,28 @@ protocol PointOfSaleOrderControllerProtocol { @Observable final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol { init(orderService: POSOrderServiceProtocol, - receiptService: POSReceiptServiceProtocol, + receiptController: POSReceiptControllerProtocol, stores: StoresManager = ServiceLocator.stores, currencySettings: CurrencySettings = ServiceLocator.currencySettings, analytics: Analytics = ServiceLocator.analytics, - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, - pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager), celebration: PaymentCaptureCelebrationProtocol = PaymentCaptureCelebration()) { self.orderService = orderService - self.receiptService = receiptService + self.receiptController = receiptController self.stores = stores self.storeCurrency = currencySettings.currencyCode self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) self.analytics = analytics - self.featureFlagService = featureFlagService - self.pluginsService = pluginsService self.celebration = celebration } private let orderService: POSOrderServiceProtocol - private let receiptService: POSReceiptServiceProtocol + private let receiptController: POSReceiptControllerProtocol private let currencyFormatter: CurrencyFormatter private let celebration: PaymentCaptureCelebrationProtocol private let storeCurrency: CurrencyCode private let analytics: Analytics private let stores: StoresManager - private let featureFlagService: FeatureFlagService - private let pluginsService: PluginsServiceProtocol private(set) var orderState: PointOfSaleInternalOrderState = .idle private var order: Order? = nil @@ -110,36 +99,11 @@ protocol PointOfSaleOrderControllerProtocol { @MainActor func sendReceipt(recipientEmail: String) async throws { - var isEligibleForPOSReceipt: Bool? - do { - guard let order else { - throw PointOfSaleOrderControllerError.noOrder - } - - try await orderService.updatePOSOrder(order: order, recipientEmail: recipientEmail) - - let posReceiptEligibility: Bool - if featureFlagService.isFeatureFlagEnabled(.pointOfSaleReceipts) { - posReceiptEligibility = isPluginSupported( - .wooCommerce, - minimumVersion: POSReceiptEligibilityConstants.wcPluginMinimumVersion, - siteID: order.siteID - ) - } else { - posReceiptEligibility = false - } - isEligibleForPOSReceipt = posReceiptEligibility - - try await receiptService.sendReceipt(order: order, recipientEmail: recipientEmail, isEligibleForPOSReceipt: posReceiptEligibility) - - analytics.track(.receiptEmailSuccess, withProperties: ["eligible_for_pos_receipt": posReceiptEligibility]) - } catch { - let properties = [ - "eligible_for_pos_receipt": isEligibleForPOSReceipt - ].compactMapValues( { $0 }) - analytics.track(.receiptEmailFailed, properties: properties, error: error) - throw error + guard let order else { + throw PointOfSaleOrderControllerError.noOrder } + + try await receiptController.sendReceipt(order: order, recipientEmail: recipientEmail) } func clearOrder() { @@ -208,22 +172,6 @@ private extension PointOfSaleOrderController { } } -private extension PointOfSaleOrderController { - @MainActor - func isPluginSupported(_ plugin: Plugin, minimumVersion: String, siteID: Int64) -> Bool { - // Plugin must be installed and active - guard let systemPlugin = pluginsService.loadPluginInStorage(siteID: siteID, plugin: plugin, isActive: true), - systemPlugin.active else { - return false - } - - // If plugin version is higher than minimum required version, mark as eligible - let isSupported = VersionHelpers.isVersionSupported(version: systemPlugin.version, - minimumRequired: minimumVersion, - includesDevAndBetaVersions: true) - return isSupported - } -} // MARK: - Error Handling @@ -240,11 +188,6 @@ private extension PointOfSaleOrderController { } } -private extension PointOfSaleOrderController { - enum POSReceiptEligibilityConstants { - static let wcPluginMinimumVersion = "10.0.0" - } -} // 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. diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index d4695dc890f..66c2f0fd088 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -75,6 +75,9 @@ 019130212CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */; }; 01929C342CEF6354006C79ED /* CardPresentModalErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */; }; 01929C362CEF6D6E006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */; }; + 019460DE2E700DF800FCB9AB /* POSReceiptController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DD2E700DF800FCB9AB /* POSReceiptController.swift */; }; + 019460E02E700E3D00FCB9AB /* POSReceiptControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DF2E700E3D00FCB9AB /* POSReceiptControllerTests.swift */; }; + 019460E22E70121A00FCB9AB /* MockPOSReceiptController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */; }; 019630B42D01DB4800219D80 /* TapToPayAwarenessMomentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */; }; 019630B62D02018C00219D80 /* TapToPayAwarenessMomentDeterminer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */; }; 019630B82D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B72D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift */; }; @@ -3288,6 +3291,9 @@ 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationViewModelTests.swift; sourceTree = ""; }; 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorWithoutEmail.swift; sourceTree = ""; }; 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalNonRetryableErrorWithoutEmail.swift; sourceTree = ""; }; + 019460DD2E700DF800FCB9AB /* POSReceiptController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptController.swift; sourceTree = ""; }; + 019460DF2E700E3D00FCB9AB /* POSReceiptControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptControllerTests.swift; sourceTree = ""; }; + 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSReceiptController.swift; sourceTree = ""; }; 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentView.swift; sourceTree = ""; }; 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentDeterminer.swift; sourceTree = ""; }; 019630B72D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentDeterminerTests.swift; sourceTree = ""; }; @@ -7790,6 +7796,7 @@ 02CD3BFC2C35D01600E575C4 /* Mocks */ = { isa = PBXGroup; children = ( + 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */, 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */, 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */, 01F935582DFC0D4800B50B03 /* MockPointOfSaleSoundPlayer.swift */, @@ -8212,6 +8219,7 @@ 200BA1572CF092150006DC5B /* Controllers */ = { isa = PBXGroup; children = ( + 019460DD2E700DF800FCB9AB /* POSReceiptController.swift */, 02E4E7452E0EF847003A31E7 /* POSEntryPointController.swift */, 68B681152D92577F0098D5CD /* PointOfSaleCouponsController.swift */, 200BA1582CF092280006DC5B /* PointOfSaleItemsController.swift */, @@ -8224,6 +8232,7 @@ 200BA15C2CF0A9D90006DC5B /* Controllers */ = { isa = PBXGroup; children = ( + 019460DF2E700E3D00FCB9AB /* POSReceiptControllerTests.swift */, 6818E7C02D93C76200677C16 /* PointOfSaleCouponsControllerTests.swift */, 20DB185C2CF5E7560018D3E1 /* PointOfSaleOrderControllerTests.swift */, 68D7480F2E5DB6D20048CFE9 /* PointOfSaleSettingsControllerTests.swift */, @@ -16612,6 +16621,7 @@ DE6906E327D7121800735E3B /* GhostTableViewController.swift in Sources */, 02EEB5C42424AFAA00B8A701 /* TextFieldTableViewCell.swift in Sources */, 453326FD2C3C5315000E4862 /* ProductCreationAIPromptProgressBarViewModel.swift in Sources */, + 019460DE2E700DF800FCB9AB /* POSReceiptController.swift in Sources */, 26E7EE6A292D688900793045 /* AnalyticsHubViewModel.swift in Sources */, AE457813275644590092F687 /* OrderStatusSection.swift in Sources */, B57C744E20F56E3800EEFC87 /* UITableViewCell+Helpers.swift in Sources */, @@ -17521,6 +17531,7 @@ EE2A57D929E39A9C009F61E1 /* CaseIterable+HelpersTests.swift in Sources */, D8AB131E225DC25F002BB5D1 /* MockOrders.swift in Sources */, EEBDF7E72A31A59F00EFEF47 /* FirstProductCreatedViewModelTests.swift in Sources */, + 019460E22E70121A00FCB9AB /* MockPOSReceiptController.swift in Sources */, DE74F2A727E47F620002FE59 /* EnableAnalyticsViewModelTests.swift in Sources */, EE289AF52C9D9C3B004AB1A6 /* ProductCreationAIStartingInfoViewModelTests.swift in Sources */, 8697AFBD2B60F56A00EFAF21 /* BlazeAdDestinationSettingViewModelTests.swift in Sources */, @@ -17709,6 +17720,7 @@ 02077F72253816FF005A78EF /* ProductFormActionsFactory+ReadonlyProductTests.swift in Sources */, D8C11A6222E24C4A00D4A88D /* LedgerTableViewCellTests.swift in Sources */, 027111422913B9FC00F5269A /* AccountCreationFormViewModelTests.swift in Sources */, + 019460E02E700E3D00FCB9AB /* POSReceiptControllerTests.swift in Sources */, 2084B7A82C776E1000EFBD2E /* PointOfSaleCardPresentPaymentFoundMultipleReadersAlertViewModelTests.swift in Sources */, 02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */, DE50295328BF4A8A00551736 /* JetpackConnectionWebViewModelTests.swift in Sources */, From fcc45508b4ffdc00a4ba97c6e833108a2b3b0c41 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:14:10 +0300 Subject: [PATCH 04/25] Move receipt-related tests to POSReceiptControllerTests --- .../POSReceiptControllerTests.swift | 194 ++++++++++ .../PointOfSaleOrderControllerTests.swift | 359 ++++-------------- .../POS/Mocks/MockPOSReceiptController.swift | 20 + 3 files changed, 281 insertions(+), 292 deletions(-) create mode 100644 WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift create mode 100644 WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift new file mode 100644 index 00000000000..e08df5edd76 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift @@ -0,0 +1,194 @@ +import Testing +import Foundation + +@testable import WooCommerce +import struct Yosemite.Order +import struct Yosemite.SystemPlugin +import protocol WooFoundation.Analytics +import enum Networking.DotcomError + +struct POSReceiptControllerTests { + let mockOrderService = MockPOSOrderService() + let mockReceiptService = MockReceiptService() + let mockAnalyticsProvider = MockAnalyticsProvider() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + let sut: POSReceiptController + + init() { + self.sut = POSReceiptController(orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + } + + @Test func sendReceipt_calls_both_updateOrder_and_sendReceipt() async throws { + // Given + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + let order = Order.fake() + let recipientEmail = "test@fake.com" + + // When + try await sut.sendReceipt(order: order, recipientEmail: recipientEmail) + + // Then + #expect(mockOrderService.updateOrderWasCalled) + #expect(mockReceiptService.sendReceiptWasCalled == true) + } + + @Test func sendReceipt_tracks_success_with_eligible_for_pos_receipt() async throws { + // Given + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: "10.0.0-dev", + active: true)) + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + let order = Order.fake() + + // When + try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + } + + @Test func sendReceipt_tracks_failure_with_eligible_for_pos_receipt() async throws { + // Given + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: "10.0.0-dev", + active: true)) + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockReceiptService.sendReceiptResult = .failure(DotcomError.unknown(code: "test_error", message: "Test error")) + let order = Order.fake() + + // When + do { + try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + #expect(Bool(false), "Expected error to be thrown") + } catch { + // Then - error was thrown as expected + #expect(mockOrderService.updateOrderWasCalled) + } + } + + @MainActor + struct PluginEligibilityTests { + private let mockOrderService = MockPOSOrderService() + + @Test("Eligible core plugin versions with feature flag enabled", arguments: Constants.eligibleWCPluginVersions) + func sendReceipt_when_feature_flag_enabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_true(wcPluginVersion: String) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: wcPluginVersion, + active: true)) + let sut = POSReceiptController(orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == true) + } + + @Test( + "All core plugin versions with feature flag disabled", + arguments: Constants.eligibleWCPluginVersions + Constants.ineligibleWCPluginVersions + ) + func sendReceipt_when_feature_flag_disabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = false + // Plugin setup is irrelevant when feature flag is disabled + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: wcPluginVersion, + active: true)) + let sut = POSReceiptController(orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) + } + + @Test("Ineligible core plugin versions with feature flag enabled", arguments: Constants.ineligibleWCPluginVersions) + func sendReceipt_when_feature_flag_enabled_and_ineligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: wcPluginVersion, + active: true)) + let sut = POSReceiptController(orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) + } + + @Test("Unavailable core plugin with feature flag enabled", + arguments: [ + SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", active: false), + nil + ]) + func sendReceipt_when_feature_flag_enabled_and_plugin_unavailable_sets_isEligibleForPOSReceipt_false(plugin: SystemPlugin?) async throws { + // Given + let mockReceiptService = MockReceiptService() + let mockFeatureFlagService = MockFeatureFlagService() + let mockPluginsService = MockPluginsService() + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, systemPlugin: plugin) + let sut = POSReceiptController(orderService: mockOrderService, + receiptService: mockReceiptService, + analytics: MockAnalytics(), + featureFlagService: mockFeatureFlagService, + pluginsService: mockPluginsService) + let order = Order.fake() + + // When + try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + + // Then + #expect(mockReceiptService.sendReceiptWasCalled == true) + #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) + } + + private enum Constants { + static let eligibleWCPluginVersions = ["10.0.0", "10.0.0-dev", "10.0.0-beta", "10.0.1", "10.1"] + static let ineligibleWCPluginVersions = ["9.9.0", "9.9.9", "9.9.9-beta.9", "9.9.9-dev"] + } + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index 8be5311ce21..b9af1e59c72 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -16,15 +16,15 @@ import enum Networking.NetworkError struct PointOfSaleOrderControllerTests { let mockOrderService = MockPOSOrderService() - let mockReceiptService = MockReceiptService() + let mockReceiptController = MockPOSReceiptController() @Test func syncOrder_without_items_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) // When - await sut.syncOrder(for: .init(), retryHandler: {}) + await sut.syncOrder(for: Cart(), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -33,17 +33,17 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_cart_matching_order_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) let cartItem = makeItem(orderItemsToMatch: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -52,16 +52,16 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_already_syncing_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) mockOrderService.simulateSyncing = true Task { - await sut.syncOrder(for: .init(purchasableItems: [makeItem(quantity: 1)]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem(quantity: 1)]), retryHandler: {}) } try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC))) mockOrderService.syncOrderWasCalled = false // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem(quantity: 2), + await sut.syncOrder(for: Cart(purchasableItems: [makeItem(quantity: 2), makeItem(quantity: 5)]), retryHandler: {}) @@ -77,11 +77,11 @@ struct PointOfSaleOrderControllerTests { decimalSeparator: ".", numberOfDecimals: 2) let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptController: mockReceiptController, currencySettings: currencySettings) // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) // Then #expect(mockOrderService.spySyncOrderCurrency == .AUD) @@ -90,7 +90,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_changes_from_previous_order_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let cartItem = makeItem(quantity: 1) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) @@ -99,7 +99,7 @@ struct PointOfSaleOrderControllerTests { let futureOrderItem = OrderItem.fake().copy(quantity: 5) // When - await sut.syncOrder(for: .init(purchasableItems: [cartItem, + await sut.syncOrder(for: Cart(purchasableItems: [cartItem, makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])]), retryHandler: {}) @@ -110,7 +110,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_no_previous_order_sets_orderState_syncing_then_loaded() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let fakeOrder = Order.fake() mockOrderService.orderToReturn = fakeOrder var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] @@ -130,7 +130,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) } await orderStateAppendTask?.value @@ -147,7 +147,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_order_sync_failure_sets_orderState_syncing_then_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) mockOrderService.orderToReturn = nil var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] @@ -167,7 +167,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) } await orderStateAppendTask?.value @@ -180,13 +180,10 @@ struct PointOfSaleOrderControllerTests { ]) } - @Test func sendReceipt_when_there_is_no_order_then_will_not_trigger() async throws { + @Test func sendReceipt_when_there_is_no_order_then_throws_noOrder_error() async throws { // Given - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - featureFlagService: mockFeatureFlagService) + receiptController: mockReceiptController) let email = "test@example.com" // When @@ -195,38 +192,35 @@ struct PointOfSaleOrderControllerTests { } catch { // Then #expect(error as? PointOfSaleOrderController.PointOfSaleOrderControllerError == .noOrder) - #expect(!mockOrderService.updateOrderWasCalled) - #expect(mockReceiptService.sendReceiptWasCalled == nil) + #expect(!mockReceiptController.sendReceiptWasCalled) } } - @Test func sendReceipt_calls_both_updateOrder_and_sendReceipt() async throws { + @Test func sendReceipt_with_order_delegates_to_receiptController() async throws { // Given - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - featureFlagService: mockFeatureFlagService) + receiptController: mockReceiptController) let order = Order.fake() let recipientEmail = "test@fake.com" mockOrderService.orderToReturn = order - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) + // We need an existing order before we can send a receipt: + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: { }) // When try await sut.sendReceipt(recipientEmail: recipientEmail) // Then - #expect(mockOrderService.updateOrderWasCalled) - #expect(mockOrderService.orderToReturn?.billingAddress?.email == recipientEmail) + #expect(mockReceiptController.sendReceiptWasCalled) + #expect(mockReceiptController.sendReceiptCalledWithOrder == order) + #expect(mockReceiptController.sendReceiptCalledWithEmail == recipientEmail) } @Test func collectCashPayment_when_no_order_then_fails_with_noOrder_error() async throws { do { // Given/When let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptController: mockReceiptController, celebration: MockPaymentCaptureCelebration()) try await sut.collectCashPayment(changeDueAmount: nil) } catch let error as PointOfSaleOrderController.PointOfSaleOrderControllerError { @@ -240,13 +234,13 @@ struct PointOfSaleOrderControllerTests { // Given let mockPaymentCelebration = MockPaymentCaptureCelebration() let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptController: mockReceiptController, celebration: mockPaymentCelebration) let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) mockOrderService.resultToReturn = .success(()) @@ -260,13 +254,13 @@ struct PointOfSaleOrderControllerTests { @Test func collectCashPayment_passes_changeDueAmount_to_order_service() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, + receiptController: mockReceiptController, celebration: MockPaymentCaptureCelebration()) let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) mockOrderService.resultToReturn = .success(()) @@ -280,14 +274,14 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_successful_returns_newOrder_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let fakeOrderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake() let fakeCartItem = makeItem(orderItemsToMatch: [fakeOrderItem]) mockOrderService.orderToReturn = fakeOrder // When - let result = await sut.syncOrder(for: .init(purchasableItems: [fakeCartItem]), retryHandler: { }) + let result = await sut.syncOrder(for: Cart(purchasableItems: [fakeCartItem]), retryHandler: { }) // Then if case .success(let state) = result { @@ -300,16 +294,16 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_updating_existing_order_returns_newOrder_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let fakeOrder = Order.fake() mockOrderService.orderToReturn = fakeOrder // When // 1. Initial order - _ = await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + _ = await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) // 2. Sync existing order - let result = await sut.syncOrder(for: .init(purchasableItems: [makeItem(), makeItem()]), retryHandler: {}) + let result = await sut.syncOrder(for: Cart(purchasableItems: [makeItem(), makeItem()]), retryHandler: {}) // Then if case .success(let state) = result { @@ -322,7 +316,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_cart_matching_order_then_returns_orderNotChanged_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) let cartItem = makeItem(orderItemsToMatch: [orderItem]) @@ -330,10 +324,10 @@ struct PointOfSaleOrderControllerTests { // When // 1. Initial order - _ = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + _ = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // 2. Syncing existing order with same cart should not update order - let result = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let result = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then if case .success(let state) = result { @@ -345,12 +339,12 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_then_returns_syncOrderState_failure() async throws { let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let cartItem = makeItem(quantity: 1) // When mockOrderService.orderToReturn = nil - let result = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let result = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then if case .failure(let error) = result { @@ -363,7 +357,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_cart_matching_order_and_coupons_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let orderItem = OrderItem.fake().copy(quantity: 1) let couponCode = "SAVE10" let coupon = OrderCouponLine.fake().copy(code: couponCode) @@ -372,12 +366,12 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync to set up the order - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - sync with same items and coupons - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -386,7 +380,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_matching_items_but_different_coupons_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let orderItem = OrderItem.fake().copy(quantity: 1) let initialCouponCode = "SAVE10" let initialCoupon = OrderCouponLine.fake().copy(code: initialCouponCode) @@ -395,12 +389,12 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: initialCouponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: initialCouponCode, summary: "")]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - sync with same items but different coupon - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: "DIFFERENT20", summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: "DIFFERENT20", summary: "")]), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == true) @@ -409,7 +403,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_matching_items_but_removed_coupon_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let orderItem = OrderItem.fake().copy(quantity: 1) let couponCode = "SAVE10" let coupon = OrderCouponLine.fake().copy(code: couponCode) @@ -418,12 +412,12 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync with coupon - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: [.init(id: UUID(), code: couponCode, summary: "")]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - sync with same items but no coupons - await sut.syncOrder(for: .init(purchasableItems: [cartItem], coupons: []), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [cartItem], coupons: []), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == true) @@ -432,7 +426,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_with_couponsError_then_sets_invalidCoupon_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let errorMessage = "Invalid coupon code" mockOrderService.errorToReturn = DotcomError.unknown(code: "woocommerce_rest_invalid_coupon", message: errorMessage) @@ -453,7 +447,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()], + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()], coupons: [.init(id: UUID(), code: "INVALID", summary: "")]), retryHandler: {}) } @@ -471,7 +465,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_with_networkError_containing_couponsError_then_sets_invalidCoupon_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) let errorMessage = "Coupon INVALID does not exist" let errorJSON = """ { @@ -499,7 +493,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()], + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()], coupons: [.init(id: UUID(), code: "INVALID", summary: "")]), retryHandler: {}) } @@ -517,7 +511,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_fails_sets_order_to_nil() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService) + receiptController: mockReceiptController) // First create a successful order let orderItem = OrderItem.fake().copy(quantity: 1) @@ -526,7 +520,7 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // Initial sync succeeds - let initialResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let initialResult = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) switch initialResult { case .success(.newOrder): break @@ -536,7 +530,7 @@ struct PointOfSaleOrderControllerTests { // Then simulate a failure mockOrderService.errorToReturn = SyncOrderStateError.syncFailure - let failureResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem, cartItem]), retryHandler: {}) + let failureResult = await sut.syncOrder(for: Cart(purchasableItems: [cartItem, cartItem]), retryHandler: {}) switch failureResult { case .failure(SyncOrderStateError.syncFailure): break @@ -547,7 +541,7 @@ struct PointOfSaleOrderControllerTests { // When - try syncing with the same cart again mockOrderService.errorToReturn = nil mockOrderService.orderToReturn = fakeOrder // Restore mock to return success - let subsequentResult = await sut.syncOrder(for: .init(purchasableItems: [cartItem]), retryHandler: {}) + let subsequentResult = await sut.syncOrder(for: Cart(purchasableItems: [cartItem]), retryHandler: {}) // Then - should be treated as new order since previous order was cleared switch subsequentResult { @@ -564,6 +558,7 @@ struct PointOfSaleOrderControllerTests { private let analyticsProvider = MockAnalyticsProvider() private let orderService = MockPOSOrderService() private let receiptService = MockReceiptService() + private let mockReceiptController = MockPOSReceiptController() init() { analytics = WooAnalytics(analyticsProvider: analyticsProvider) @@ -572,7 +567,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_create_order_then_tracks_order_creation_success_event() async throws { // Given let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, + receiptController: mockReceiptController, analytics: analytics) let fakeOrderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake() @@ -580,7 +575,7 @@ struct PointOfSaleOrderControllerTests { orderService.orderToReturn = fakeOrder // When - await sut.syncOrder(for: .init(purchasableItems: [fakeCartItem]), retryHandler: { }) + await sut.syncOrder(for: Cart(purchasableItems: [fakeCartItem]), retryHandler: { }) // Then #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_success" }) != nil) @@ -589,12 +584,12 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_create_order_fails_with_order_service_error_then_tracks_order_creation_failure_event() async throws { // Given let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, + receiptController: mockReceiptController, analytics: analytics) orderService.orderToReturn = nil // When - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) // Then #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_failed" }) != nil) @@ -607,7 +602,7 @@ struct PointOfSaleOrderControllerTests { let mockAnalytics = WooAnalytics(analyticsProvider: mockAnalyticsProvider) let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: MockReceiptService(), + receiptController: mockReceiptController, analytics: mockAnalytics, celebration: MockPaymentCaptureCelebration()) @@ -615,13 +610,13 @@ struct PointOfSaleOrderControllerTests { let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) orderService.orderToReturn = fakeOrder - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: {}) + await sut.syncOrder(for: Cart(purchasableItems: [makeItem()]), retryHandler: {}) orderService.resultToReturn = .failure(NSError(domain: "test", code: 0, userInfo: nil)) // When await #expect(performing: { - try await sut.collectCashPayment(changeDueAmount: nil) + try await sut.collectCashPayment(changeDueAmount: nil as String?) }, throws: { _ in return true }) @@ -630,228 +625,8 @@ struct PointOfSaleOrderControllerTests { #expect(mockAnalyticsProvider.receivedEvents.first(where: { $0 == "cash_payment_failed" }) != nil) } - @Test func sendReceipt_tracks_success_with_eligible_for_pos_receipt() async throws { - // Given - let mockPluginsService = MockPluginsService() - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: "10.0.0-dev", - active: true)) - - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - - let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, - analytics: analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - let order = Order.fake() - orderService.orderToReturn = order - - // We need an existing order before we can send a receipt - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - analyticsProvider.receivedEvents.removeAll() - analyticsProvider.receivedProperties.removeAll() - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_success" })) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] as? Bool == true) - } - @Test func sendReceipt_without_order_tracks_failure_without_eligible_for_pos_receipt() async throws { - // Given - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, - analytics: analytics, - featureFlagService: mockFeatureFlagService) - - // When - do { - try await sut.sendReceipt(recipientEmail: "test@example.com") - } catch { - // Then - let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_failed" })) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] == nil) - } - } - - @Test func sendReceipt_tracks_failure_with_eligible_for_pos_receipt() async throws { - // Given - let mockPluginsService = MockPluginsService() - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: "10.0.0-dev", - active: true)) - - let mockFeatureFlagService = MockFeatureFlagService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - let sut = PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService, - analytics: analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - - receiptService.sendReceiptResult = .failure(DotcomError.unknown(code: "test_error", message: "Test error")) - - let order = Order.fake() - orderService.orderToReturn = order - - // We need an existing order before we can send a receipt - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - analyticsProvider.receivedEvents.removeAll() - analyticsProvider.receivedProperties.removeAll() - - // When - do { - try await sut.sendReceipt(recipientEmail: "test@example.com") - } catch { - // Then - let indexOfEvent = try #require(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "receipt_email_failed" })) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["eligible_for_pos_receipt"] as? Bool == true) - #expect(analyticsProvider.receivedProperties[indexOfEvent]["error_description"] as? String != nil) - } - } - } - - @MainActor - struct ReceiptTests { - private let mockOrderService = MockPOSOrderService() - - @Test("Eligible core plugin versions with feature flag enabled", arguments: Constants.eligibleWCPluginVersions) - func sendReceipt_when_feature_flag_enabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_true(wcPluginVersion: String) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: wcPluginVersion, - active: true)) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == true) - } - - @Test( - "All core plugin versions with feature flag disabled", - arguments: Constants.eligibleWCPluginVersions + Constants.ineligibleWCPluginVersions - ) - func sendReceipt_when_feature_flag_disabled_and_eligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = false - // Plugin setup is irrelevant when feature flag is disabled - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: wcPluginVersion, - active: true)) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) - } - - @Test("Ineligible core plugin versions with feature flag enabled", arguments: Constants.ineligibleWCPluginVersions) - func sendReceipt_when_feature_flag_enabled_and_ineligible_plugin_version_sets_isEligibleForPOSReceipt_false(wcPluginVersion: String) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - mockPluginsService.setMockPlugin(.wooCommerce, - systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", - version: wcPluginVersion, - active: true)) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) - } - - @Test("Unavailable core plugin with feature flag enabled", - arguments: [ - SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", active: false), - nil - ]) - func sendReceipt_when_feature_flag_enabled_and_plugin_unavailable_sets_isEligibleForPOSReceipt_false(plugin: SystemPlugin?) async throws { - // Given - let mockReceiptService = MockReceiptService() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - mockPluginsService.setMockPlugin(.wooCommerce, systemPlugin: plugin) - - let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptService: mockReceiptService, - analytics: ServiceLocator.analytics, - featureFlagService: mockFeatureFlagService, - pluginsService: mockPluginsService) - mockOrderService.orderToReturn = Order.fake() - - // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: .init(purchasableItems: [makeItem()]), retryHandler: { }) - - // When - try await sut.sendReceipt(recipientEmail: "test@example.com") - - // Then - #expect(mockReceiptService.sendReceiptWasCalled == true) - #expect(mockReceiptService.spyIsEligibleForPOSReceipt == false) - } - - private enum Constants { - static let eligibleWCPluginVersions = ["10.0.0", "10.0.0-dev", "10.0.0-beta", "10.0.1", "10.1"] - static let ineligibleWCPluginVersions = ["9.9.0", "9.9.9", "9.9.9-beta.9", "9.9.9-dev"] - } } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift new file mode 100644 index 00000000000..4c4f38460f4 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import WooCommerce +import struct Yosemite.Order + +final class MockPOSReceiptController: POSReceiptControllerProtocol { + var shouldThrowReceiptError: Bool = false + var sendReceiptWasCalled: Bool = false + var sendReceiptCalledWithOrder: Order? + var sendReceiptCalledWithEmail: String? + + func sendReceipt(order: Order, recipientEmail: String) async throws { + sendReceiptWasCalled = true + sendReceiptCalledWithOrder = order + sendReceiptCalledWithEmail = recipientEmail + + if shouldThrowReceiptError { + throw PointOfSaleOrderController.PointOfSaleOrderControllerError.noOrder + } + } +} From c2dfab90d9c3f9ffbb5d2dca4f79b4edda85ae5e Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:15:00 +0300 Subject: [PATCH 05/25] Update POSSendReceiptView to allow injecting receipt-sending logic --- .../PointOfSalePaymentSuccessView.swift | 10 ++++++---- .../Reusable Views/POSSendReceiptView.swift | 14 ++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift index 1c77d2ddccd..e6bceb11497 100644 --- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift +++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSalePaymentSuccessView.swift @@ -12,10 +12,12 @@ struct PointOfSalePaymentSuccessView: View { var body: some View { VStack { if isShowingSendReceiptView { - POSSendReceiptView(isShowingSendReceiptView: $isShowingSendReceiptView) - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .trailing).combined(with: .opacity))) + POSSendReceiptView(isShowingSendReceiptView: $isShowingSendReceiptView) { email in + try await posModel.sendReceipt(to: email) + } + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity))) } else { HStack(alignment: .center) { Spacer() diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift index 2b06fe75e1c..da13d0a9a57 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift @@ -4,7 +4,6 @@ import WooFoundation import class WordPressShared.EmailFormatValidator struct POSSendReceiptView: View { - @Environment(PointOfSaleAggregateModel.self) private var posModel @Environment(\.dynamicTypeSize) var dynamicTypeSize @State private var textFieldInput: String = "" @State private var isLoading: Bool = false @@ -12,11 +11,17 @@ struct POSSendReceiptView: View { @FocusState private var isTextFieldFocused: Bool @Binding private(set) var isShowingSendReceiptView: Bool + private let onSendReceipt: (String) async throws -> Void @State private var buttonFrame: CGRect = .zero @State private var keyboardFrame: CGRect = .zero @State private var shouldMinimizePadding: Bool = false + init(isShowingSendReceiptView: Binding, onSendReceipt: @escaping (String) async throws -> Void) { + self._isShowingSendReceiptView = isShowingSendReceiptView + self.onSendReceipt = onSendReceipt + } + private var isEmailValid: Bool { EmailFormatValidator.validate(string: textFieldInput) } @@ -101,7 +106,7 @@ struct POSSendReceiptView: View { isLoading = true do { errorMessage = nil - try await posModel.sendReceipt(to: textFieldInput) + try await onSendReceipt(textFieldInput) withAnimation { isShowingSendReceiptView = false isTextFieldFocused = false @@ -154,7 +159,8 @@ private extension POSSendReceiptView { #if DEBUG #Preview { - POSSendReceiptView(isShowingSendReceiptView: .constant(true)) - .environment(POSPreviewHelpers.makePreviewAggregateModel()) + POSSendReceiptView(isShowingSendReceiptView: .constant(true)) { email in + try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } } #endif From acb223b07f145e40becbe190435e31095f77f3e8 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:15:16 +0300 Subject: [PATCH 06/25] Update POSTabCoordinator dependency injection --- WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index cb36b197379..353333d7ed4 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -139,7 +139,8 @@ private extension POSTabCoordinator { }, cardPresentPaymentService: cardPresentPaymentService, orderController: PointOfSaleOrderController(orderService: orderService, - receiptService: receiptService), + receiptController: POSReceiptController(orderService: orderService, + receiptService: receiptService)), settingsController: PointOfSaleSettingsController(siteID: siteID, settingsService: settingsService, cardPresentPaymentService: cardPresentPaymentService, From 68b8acacf728989360434de956217e96bde6d2b4 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:44:54 +0300 Subject: [PATCH 07/25] Create updatePOSOrderEmail in OrdersRemote for targeted email update on Order --- .../NetworkingCore/Remote/OrdersRemote.swift | 18 ++++++++++++++++++ .../Remote/POSOrdersRemoteProtocol.swift | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift index 29c7a03e4a5..31855bda7f9 100644 --- a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift +++ b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift @@ -451,6 +451,24 @@ extension OrdersRemote: POSOrdersRemoteProtocol { } } + public func updatePOSOrderEmail(siteID: Int64, orderID: Int64, emailAddress: String) async throws { + let parameters: [String: Any] = [ + "billing": [ + "email": emailAddress + ] + ] + + let path = "\(Constants.ordersPath)/\(orderID)" + let request = JetpackRequest(wooApiVersion: .mark3, + method: .post, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true) + + try await enqueue(request) + } + public func loadPOSOrders(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> PagedItems { let parameters: [String: Any] = [ ParameterKeys.page: String(pageNumber), diff --git a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift index 3d301afa3ac..56c1bd0ae3f 100644 --- a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift +++ b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift @@ -11,6 +11,10 @@ public protocol POSOrdersRemoteProtocol { cashPaymentChangeDueAmount: String?, fields: [OrdersRemote.UpdateOrderField]) async throws -> Order + func updatePOSOrderEmail(siteID: Int64, + orderID: Int64, + emailAddress: String) async throws + func createPOSOrder(siteID: Int64, order: Order, fields: [OrdersRemote.CreateOrderField]) async throws -> Order From c60ac56391136e6efd866e0052d8f1f290806e11 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:46:19 +0300 Subject: [PATCH 08/25] Remove dependency of Order from POSOrderService updatePOSOrder email --- .../Yosemite/Tools/POS/POSOrderService.swift | 17 ++-------- .../Mocks/MockPOSOrdersRemote.swift | 19 ++++++++++++ .../Tools/POS/POSOrderServiceTests.swift | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift index 5f3338e0743..255733e0e78 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift @@ -9,7 +9,7 @@ public protocol POSOrderServiceProtocol { /// - cart: Cart with different types of items and quantities. /// - Returns: Order from the remote sync. func syncOrder(cart: POSCart, currency: CurrencyCode) async throws -> Order - func updatePOSOrder(order: Order, recipientEmail: String) async throws + func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws func markOrderAsCompletedWithCashPayment(order: Order, changeDueAmount: String?) async throws } @@ -46,20 +46,9 @@ public final class POSOrderService: POSOrderServiceProtocol { return try await ordersRemote.createPOSOrder(siteID: siteID, order: order, fields: [.items, .status, .currency, .couponLines]) } - public func updatePOSOrder(order: Order, recipientEmail: String) async throws { - guard order.billingAddress?.email == nil || order.billingAddress?.email == "" else { - throw POSOrderServiceError.emailAlreadySet - } - let updatedBillingAddress = order.billingAddress?.copy(email: recipientEmail) - let updatedOrder = order.copy(billingAddress: updatedBillingAddress) - + public func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws { do { - let _ = try await ordersRemote.updatePOSOrder( - siteID: siteID, - order: updatedOrder, - cashPaymentChangeDueAmount: nil, - fields: [.billingAddress] - ) + try await ordersRemote.updatePOSOrderEmail(siteID: siteID, orderID: orderID, emailAddress: recipientEmail) } catch { throw POSOrderServiceError.updateOrderFailed } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index 9a1cdaba963..9d0f7208640 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -22,6 +22,25 @@ final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { } } + var updatePOSOrderEmailCalled: Bool = false + var spyUpdatePOSOrderEmailSiteID: Int64? + var spyUpdatePOSOrderEmailOrderID: Int64? + var spyUpdatePOSOrderEmailAddress: String? + var updatePOSOrderEmailResult: Result = .success(()) + + func updatePOSOrderEmail(siteID: Int64, orderID: Int64, emailAddress: String) async throws { + updatePOSOrderEmailCalled = true + spyUpdatePOSOrderEmailSiteID = siteID + spyUpdatePOSOrderEmailOrderID = orderID + spyUpdatePOSOrderEmailAddress = emailAddress + switch updatePOSOrderEmailResult { + case .success: + return + case .failure(let error): + throw error + } + } + var createPOSOrderCalled: Bool = false var spyCreatePOSOrder: Order? var spyCreatePOSOrderFields: [OrdersRemote.CreateOrderField]? diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift index d2ded1ebadd..61701d99a63 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift @@ -166,6 +166,37 @@ struct POSOrderServiceTests { return true }) } + + @Test + func updatePOSOrder_calls_remote_updatePOSOrderEmail_with_correct_parameters() async throws { + // Given + let siteID: Int64 = 123 + let orderID: Int64 = 456 + let recipientEmail = "test@example.com" + + // When + try await sut.updatePOSOrder(orderID: orderID, recipientEmail: recipientEmail) + + // Then + #expect(mockOrdersRemote.updatePOSOrderEmailCalled == true) + #expect(mockOrdersRemote.spyUpdatePOSOrderEmailSiteID == siteID) + #expect(mockOrdersRemote.spyUpdatePOSOrderEmailOrderID == orderID) + #expect(mockOrdersRemote.spyUpdatePOSOrderEmailAddress == recipientEmail) + } + + @Test + func updatePOSOrder_throws_error_when_remote_call_fails() async throws { + // Given + mockOrdersRemote.updatePOSOrderEmailResult = .failure(NSError(domain: "", code: 0)) + + // When/Then + await #expect(performing: { + try await sut.updatePOSOrder(orderID: 456, recipientEmail: "test@example.com") + }, throws: { _ in + // The actual error `POSOrderServiceError.updateOrderFailed` is private, thus we cannot check against the exact error. + return true + }) + } } private func makePOSCartItem( From 3be0b8c3d8cca763d4af9a876afd8d2d424a7576 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:46:35 +0300 Subject: [PATCH 09/25] Remove Order dependency from POSReceiptService sendReceipt method --- .../Sources/Yosemite/Tools/POS/POSReceiptService.swift | 8 ++++---- .../YosemiteTests/Tools/POS/POSReceiptServiceTests.swift | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift index 5f304badf82..436ef977833 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift @@ -2,7 +2,7 @@ import SwiftUI import Networking public protocol POSReceiptServiceProtocol { - func sendReceipt(order: Order, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws + func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws } public final class POSReceiptService: POSReceiptServiceProtocol { @@ -25,12 +25,12 @@ public final class POSReceiptService: POSReceiptServiceProtocol { self.receiptsRemote = receiptsRemote } - public func sendReceipt(order: Yosemite.Order, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { + public func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { do { if isEligibleForPOSReceipt { - try await receiptsRemote.sendPOSReceipt(siteID: siteID, orderID: order.orderID) + try await receiptsRemote.sendPOSReceipt(siteID: siteID, orderID: orderID) } else { - try await receiptsRemote.sendReceipt(siteID: siteID, orderID: order.orderID) + try await receiptsRemote.sendReceipt(siteID: siteID, orderID: orderID) } } catch { throw POSReceiptServiceError.sendReceiptFailed(underlyingError: error as NSError) diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift index 7f8ddcc3738..2f54df923d8 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift @@ -20,7 +20,7 @@ struct POSReceiptServiceTests { let email = "test@example.com" // When - try await sut.sendReceipt(order: order, recipientEmail: email, isEligibleForPOSReceipt: false) + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: email, isEligibleForPOSReceipt: false) // Then #expect(receiptsRemote.sendReceiptCalled) @@ -36,7 +36,7 @@ struct POSReceiptServiceTests { // When/Then do { - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com", isEligibleForPOSReceipt: false) + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com", isEligibleForPOSReceipt: false) XCTFail("Expected error to be thrown") } catch { guard case POSReceiptService.POSReceiptServiceError.sendReceiptFailed = error else { @@ -49,7 +49,7 @@ struct POSReceiptServiceTests { @Test func sendReceipt_calls_remote_when_isEligibleForPOSReceipt_is_true() async throws { // When - try await sut.sendReceipt(order: Order.fake(), recipientEmail: "test@example.com", isEligibleForPOSReceipt: true) + try await sut.sendReceipt(orderID: Order.fake().orderID, recipientEmail: "test@example.com", isEligibleForPOSReceipt: true) // Then #expect(receiptsRemote.sendPOSReceiptCalled) From 348d86e2ec89ae10a0b43ad8bc9482eaaf68e06f Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:05:48 +0300 Subject: [PATCH 10/25] Update POSReceiptController to rely on orderID and not Order --- .../Controllers/POSReceiptController.swift | 15 ++++++---- .../PointOfSaleOrderController.swift | 2 +- .../Classes/POS/Utils/PreviewHelpers.swift | 6 +++- .../POSReceiptControllerTests.swift | 29 +++++++++++-------- .../PointOfSaleOrderControllerTests.swift | 2 +- .../POS/Mocks/MockPOSOrderService.swift | 2 +- .../POS/Mocks/MockPOSReceiptController.swift | 6 ++-- .../MockPointOfSaleOrderController.swift | 6 +--- .../POS/Mocks/MockReceiptService.swift | 2 +- 9 files changed, 39 insertions(+), 31 deletions(-) diff --git a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift index 6369579c6ef..9fafd259219 100644 --- a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift +++ b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift @@ -11,15 +11,17 @@ import protocol WooFoundation.Analytics import class Yosemite.PluginsService protocol POSReceiptControllerProtocol { - func sendReceipt(order: Order, recipientEmail: String) async throws + func sendReceipt(orderID: Int64, recipientEmail: String) async throws } final class POSReceiptController: POSReceiptControllerProtocol { - init(orderService: POSOrderServiceProtocol, + init(siteID: Int64, + orderService: POSOrderServiceProtocol, receiptService: POSReceiptServiceProtocol, analytics: Analytics = ServiceLocator.analytics, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager)) { + self.siteID = siteID self.orderService = orderService self.receiptService = receiptService self.analytics = analytics @@ -27,6 +29,7 @@ final class POSReceiptController: POSReceiptControllerProtocol { self.pluginsService = pluginsService } + private let siteID: Int64 private let orderService: POSOrderServiceProtocol private let receiptService: POSReceiptServiceProtocol private let analytics: Analytics @@ -34,24 +37,24 @@ final class POSReceiptController: POSReceiptControllerProtocol { private let pluginsService: PluginsServiceProtocol @MainActor - func sendReceipt(order: Order, recipientEmail: String) async throws { + func sendReceipt(orderID: Int64, recipientEmail: String) async throws { var isEligibleForPOSReceipt: Bool? do { - try await orderService.updatePOSOrder(order: order, recipientEmail: recipientEmail) + try await orderService.updatePOSOrder(orderID: orderID, recipientEmail: recipientEmail) let posReceiptEligibility: Bool if featureFlagService.isFeatureFlagEnabled(.pointOfSaleReceipts) { posReceiptEligibility = isPluginSupported( .wooCommerce, minimumVersion: POSReceiptEligibilityConstants.wcPluginMinimumVersion, - siteID: order.siteID + siteID: siteID ) } else { posReceiptEligibility = false } isEligibleForPOSReceipt = posReceiptEligibility - try await receiptService.sendReceipt(order: order, recipientEmail: recipientEmail, isEligibleForPOSReceipt: posReceiptEligibility) + try await receiptService.sendReceipt(orderID: orderID, recipientEmail: recipientEmail, isEligibleForPOSReceipt: posReceiptEligibility) analytics.track(.receiptEmailSuccess, withProperties: ["eligible_for_pos_receipt": posReceiptEligibility]) } catch { diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index 630af9b56ff..77ee1db7101 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -103,7 +103,7 @@ protocol PointOfSaleOrderControllerProtocol { throw PointOfSaleOrderControllerError.noOrder } - try await receiptController.sendReceipt(order: order, recipientEmail: recipientEmail) + try await receiptController.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) } func clearOrder() { diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index b3807f17d4a..650ea9fb83d 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -238,7 +238,7 @@ struct POSPreviewHelpers { } static func makePreviewOrdersModel() -> PointOfSaleOrderListModel { - return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController()) + return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController(), receiptController: POSReceiptControllerPreview()) } static func makePreviewOrder() -> POSOrder { @@ -392,6 +392,10 @@ final class PointOfSalePreviewBarcodeScanService: PointOfSaleBarcodeScanServiceP } } +final class POSReceiptControllerPreview: POSReceiptControllerProtocol { + func sendReceipt(orderID: Int64, recipientEmail: String) async throws {} +} + final class POSCollectOrderPaymentPreviewAnalytics: POSCollectOrderPaymentAnalyticsTracking { func trackCustomerInteractionStarted() {} diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift index e08df5edd76..e4b3c43ae6c 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift @@ -16,7 +16,8 @@ struct POSReceiptControllerTests { let sut: POSReceiptController init() { - self.sut = POSReceiptController(orderService: mockOrderService, + self.sut = POSReceiptController(siteID: 123, + orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), featureFlagService: mockFeatureFlagService, @@ -30,7 +31,7 @@ struct POSReceiptControllerTests { let recipientEmail = "test@fake.com" // When - try await sut.sendReceipt(order: order, recipientEmail: recipientEmail) + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) // Then #expect(mockOrderService.updateOrderWasCalled) @@ -47,7 +48,7 @@ struct POSReceiptControllerTests { let order = Order.fake() // When - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") // Then #expect(mockReceiptService.sendReceiptWasCalled == true) @@ -65,7 +66,7 @@ struct POSReceiptControllerTests { // When do { - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") #expect(Bool(false), "Expected error to be thrown") } catch { // Then - error was thrown as expected @@ -88,7 +89,8 @@ struct POSReceiptControllerTests { systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", version: wcPluginVersion, active: true)) - let sut = POSReceiptController(orderService: mockOrderService, + let sut = POSReceiptController(siteID: 123, + orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), featureFlagService: mockFeatureFlagService, @@ -96,7 +98,7 @@ struct POSReceiptControllerTests { let order = Order.fake() // When - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") // Then #expect(mockReceiptService.sendReceiptWasCalled == true) @@ -118,7 +120,8 @@ struct POSReceiptControllerTests { systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", version: wcPluginVersion, active: true)) - let sut = POSReceiptController(orderService: mockOrderService, + let sut = POSReceiptController(siteID: 123, + orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), featureFlagService: mockFeatureFlagService, @@ -126,7 +129,7 @@ struct POSReceiptControllerTests { let order = Order.fake() // When - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") // Then #expect(mockReceiptService.sendReceiptWasCalled == true) @@ -144,7 +147,8 @@ struct POSReceiptControllerTests { systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", version: wcPluginVersion, active: true)) - let sut = POSReceiptController(orderService: mockOrderService, + let sut = POSReceiptController(siteID: 123, + orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), featureFlagService: mockFeatureFlagService, @@ -152,7 +156,7 @@ struct POSReceiptControllerTests { let order = Order.fake() // When - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") // Then #expect(mockReceiptService.sendReceiptWasCalled == true) @@ -171,7 +175,8 @@ struct POSReceiptControllerTests { let mockPluginsService = MockPluginsService() mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true mockPluginsService.setMockPlugin(.wooCommerce, systemPlugin: plugin) - let sut = POSReceiptController(orderService: mockOrderService, + let sut = POSReceiptController(siteID: 123, + orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), featureFlagService: mockFeatureFlagService, @@ -179,7 +184,7 @@ struct POSReceiptControllerTests { let order = Order.fake() // When - try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: "test@example.com") // Then #expect(mockReceiptService.sendReceiptWasCalled == true) diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index b9af1e59c72..4522136353e 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -212,7 +212,7 @@ struct PointOfSaleOrderControllerTests { // Then #expect(mockReceiptController.sendReceiptWasCalled) - #expect(mockReceiptController.sendReceiptCalledWithOrder == order) + #expect(mockReceiptController.sendReceiptCalledWithOrderID == order.orderID) #expect(mockReceiptController.sendReceiptCalledWithEmail == recipientEmail) } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift index 90bf222bd07..6c33349ebed 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift @@ -33,7 +33,7 @@ class MockPOSOrderService: POSOrderServiceProtocol { return order } - func updatePOSOrder(order: Order, recipientEmail: String) async throws { + func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws { updateOrderWasCalled = true let orderWithUpdatedEmail = MockOrders().sampleOrder().copy(billingAddress: .init(firstName: "", diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift index 4c4f38460f4..64b353998e4 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift @@ -5,12 +5,12 @@ import struct Yosemite.Order final class MockPOSReceiptController: POSReceiptControllerProtocol { var shouldThrowReceiptError: Bool = false var sendReceiptWasCalled: Bool = false - var sendReceiptCalledWithOrder: Order? + var sendReceiptCalledWithOrderID: Int64? var sendReceiptCalledWithEmail: String? - func sendReceipt(order: Order, recipientEmail: String) async throws { + func sendReceipt(orderID: Int64, recipientEmail: String) async throws { sendReceiptWasCalled = true - sendReceiptCalledWithOrder = order + sendReceiptCalledWithOrderID = orderID sendReceiptCalledWithEmail = recipientEmail if shouldThrowReceiptError { diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift index 575935eb212..cec2a69d654 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift @@ -9,12 +9,8 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol { // no-op } - var orderStatePublisher: AnyPublisher { - $orderState.eraseToAnyPublisher() - } - @Published var orderState: PointOfSaleInternalOrderState = .idle + var orderState: PointOfSaleInternalOrderState = .idle var orderStateToReturn: PointOfSaleInternalOrderState? - var syncOrderWasCalled: Bool = false var spyCartProducts: [Cart.PurchasableItem]? var spyRetryHandler: (() async -> Void)? diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift index 76bb2c0f7c1..177b93cbdf2 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift @@ -6,7 +6,7 @@ final class MockReceiptService: POSReceiptServiceProtocol { var spyIsEligibleForPOSReceipt: Bool? var sendReceiptResult: Result = .success(()) - func sendReceipt(order: Yosemite.Order, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { + func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { sendReceiptWasCalled = true spyIsEligibleForPOSReceipt = isEligibleForPOSReceipt switch sendReceiptResult { From 134e27b836bb27844b3cf45fa1f0ffb6df516bea Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:27:23 +0300 Subject: [PATCH 11/25] Send receipt from PointOfSaleOrderDetailsView --- .../POS/Models/PointOfSaleOrderListModel.swift | 10 +++++++++- .../Orders/PointOfSaleOrderDetailsView.swift | 14 +++++++++++++- .../Presentation/PointOfSaleEntryPointView.swift | 6 +++++- .../Classes/POS/TabBar/POSTabCoordinator.swift | 8 ++++++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift index 43d29811d51..fc528a860b0 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift @@ -1,10 +1,18 @@ import Foundation import Observation +import struct Yosemite.POSOrder @Observable final class PointOfSaleOrderListModel { let ordersController: PointOfSaleSearchingOrderListControllerProtocol + let receiptController: POSReceiptControllerProtocol - init(ordersController: PointOfSaleSearchingOrderListControllerProtocol) { + init(ordersController: PointOfSaleSearchingOrderListControllerProtocol, + receiptController: POSReceiptControllerProtocol) { self.ordersController = ordersController + self.receiptController = receiptController + } + + func sendReceipt(order: POSOrder, email: String) async throws { + try await receiptController.sendReceipt(orderID: order.id, recipientEmail: email) } } diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index 7f159b2aa28..557cc36a552 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -10,6 +10,8 @@ struct PointOfSaleOrderDetailsView: View { let onBack: () -> Void @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(PointOfSaleOrderListModel.self) private var orderListModel + @State private var isShowingEmailReceiptView: Bool = false private var shouldShowBackButton: Bool { horizontalSizeClass == .compact @@ -40,6 +42,11 @@ struct PointOfSaleOrderDetailsView: View { } .background(Color.posSurface) .navigationBarHidden(true) + .posFullScreenCover(isPresented: $isShowingEmailReceiptView) { + POSSendReceiptView(isShowingSendReceiptView: $isShowingEmailReceiptView) { email in + try await orderListModel.sendReceipt(order: order, email: email) + } + } } } @@ -313,7 +320,12 @@ private extension PointOfSaleOrderDetailsView { func actionsSection(_ actions: [POSOrderDetailsAction]) -> some View { HStack { ForEach(actions) { action in - Button(action: {}) { + Button(action: { + switch action { + case .emailReceipt: + isShowingEmailReceiptView = true + } + }) { Text(action.title) } .buttonStyle(POSOutlinedButtonStyle(size: .extraSmall)) diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index 093317f8b7c..c3951df0307 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -16,6 +16,7 @@ struct PointOfSaleEntryPointView: View { private let couponsController: PointOfSaleCouponsControllerProtocol private let couponsSearchController: PointOfSaleSearchingItemsControllerProtocol private let ordersController: PointOfSaleSearchingOrderListControllerProtocol + private let receiptController: POSReceiptControllerProtocol private let cardPresentPaymentService: CardPresentPaymentFacade private let orderController: PointOfSaleOrderControllerProtocol private let settingsController: PointOfSaleSettingsControllerProtocol @@ -32,6 +33,7 @@ struct PointOfSaleEntryPointView: View { onPointOfSaleModeActiveStateChange: @escaping ((Bool) -> Void), cardPresentPaymentService: CardPresentPaymentFacade, orderController: PointOfSaleOrderControllerProtocol, + receiptController: POSReceiptControllerProtocol, settingsController: PointOfSaleSettingsControllerProtocol, collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking, searchHistoryService: POSSearchHistoryProviding, @@ -46,6 +48,7 @@ struct PointOfSaleEntryPointView: View { self.couponsSearchController = couponsSearchController self.cardPresentPaymentService = cardPresentPaymentService self.orderController = orderController + self.receiptController = receiptController self.settingsController = settingsController self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService @@ -85,7 +88,7 @@ struct PointOfSaleEntryPointView: View { .environmentObject(posModalManager) .environmentObject(posSheetManager) .environmentObject(posCoverManager) - .environment(PointOfSaleOrderListModel(ordersController: ordersController)) + .environment(PointOfSaleOrderListModel(ordersController: ordersController, receiptController: receiptController)) .injectKeyboardObserver() .onAppear { onPointOfSaleModeActiveStateChange(true) @@ -108,6 +111,7 @@ struct PointOfSaleEntryPointView: View { onPointOfSaleModeActiveStateChange: { _ in }, cardPresentPaymentService: CardPresentPaymentPreviewService(), orderController: PointOfSalePreviewOrderController(), + receiptController: POSReceiptControllerPreview(), settingsController: PointOfSaleSettingsPreviewController(), collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentPreviewAnalytics(), searchHistoryService: PointOfSalePreviewHistoryService(), diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 353333d7ed4..4c28fa0cb80 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -106,11 +106,15 @@ private extension POSTabCoordinator { credentials: credentials, storage: storageManager) let pluginsService = PluginsService(storageManager: storageManager) + if let receiptService = POSReceiptService(siteID: siteID, credentials: credentials), let orderService = POSOrderService(siteID: siteID, credentials: credentials), #available(iOS 17.0, *) { + let receiptController = POSReceiptController(siteID: siteID, + orderService: orderService, + receiptService: receiptService) let posView = PointOfSaleEntryPointView( itemsController: PointOfSaleItemsController( itemProvider: PointOfSaleItemService( @@ -139,8 +143,8 @@ private extension POSTabCoordinator { }, cardPresentPaymentService: cardPresentPaymentService, orderController: PointOfSaleOrderController(orderService: orderService, - receiptController: POSReceiptController(orderService: orderService, - receiptService: receiptService)), + receiptController: receiptController), + receiptController: receiptController, settingsController: PointOfSaleSettingsController(siteID: siteID, settingsService: settingsService, cardPresentPaymentService: cardPresentPaymentService, From c6c29995a8af63ff62874c82a727b0a5de461848 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:53:11 +0300 Subject: [PATCH 12/25] Allow modifying posHeaderBackButton through a view modifier --- .../Mocks/MockPOSOrdersRemote.swift | 2 +- .../Orders/PointOfSaleOrderDetailsView.swift | 1 + .../Reusable Views/POSPageHeaderView.swift | 43 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index 9d0f7208640..5111033a235 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -27,7 +27,7 @@ final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { var spyUpdatePOSOrderEmailOrderID: Int64? var spyUpdatePOSOrderEmailAddress: String? var updatePOSOrderEmailResult: Result = .success(()) - + func updatePOSOrderEmail(siteID: Int64, orderID: Int64, emailAddress: String) async throws { updatePOSOrderEmailCalled = true spyUpdatePOSOrderEmailSiteID = siteID diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index 557cc36a552..db56d6bb32a 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -46,6 +46,7 @@ struct PointOfSaleOrderDetailsView: View { POSSendReceiptView(isShowingSendReceiptView: $isShowingEmailReceiptView) { email in try await orderListModel.sendReceipt(order: order, email: email) } + .posHeaderBackButton(.init(state: .enabled, action: { isShowingEmailReceiptView = false }, buttonIcon: "xmark")) } } } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift index c8bbbabf990..99a008005cb 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift @@ -1,5 +1,18 @@ import SwiftUI +// MARK: - Environment Key for Header Back Button Configuration + +struct POSHeaderBackButtonConfigurationKey: EnvironmentKey { + static let defaultValue: POSPageHeaderBackButtonConfiguration? = nil +} + +extension EnvironmentValues { + var posHeaderBackButtonConfiguration: POSPageHeaderBackButtonConfiguration? { + get { self[POSHeaderBackButtonConfigurationKey.self] } + set { self[POSHeaderBackButtonConfigurationKey.self] = newValue } + } +} + /// Configuration for the back button in the header. struct POSPageHeaderBackButtonConfiguration { enum State { @@ -44,13 +57,18 @@ struct POSPageHeaderView some View { + content.environment(\.posHeaderBackButtonConfiguration, configuration) + } +} + +// MARK: - View Extensions for Easy Usage + +extension View { + /// Applies a back button configuration to all POSPageHeaderView instances in the view hierarchy. + /// - Parameter configuration: The back button configuration to apply, or nil to disable + /// - Returns: A view with the environment configuration set + func posHeaderBackButton(_ configuration: POSPageHeaderBackButtonConfiguration?) -> some View { + modifier(POSHeaderBackButtonModifier(configuration: configuration)) + } +} + // MARK: - Previews #Preview { From 5f87a427ace7aafea4a16132c37a394b56a47831 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:53:22 +0300 Subject: [PATCH 13/25] Speed up POS Receipt Sending - only update order when sending with legacy API endpoint - send_email from 9.8 allows to pass email and force_email_update parameters - send_order_details requires to update order before sending the receipt --- .../Networking/Remote/ReceiptRemote.swift | 10 ++-- .../Remote/POSOrdersRemoteProtocol.swift | 2 +- .../Tools/POS/POSReceiptService.swift | 2 +- .../Remote/ReceiptRemoteTests.swift | 8 +++- .../Mocks/MockPOSReceiptsRemote.swift | 10 +++- .../Tools/POS/POSReceiptServiceTests.swift | 9 +++- .../Controllers/POSReceiptController.swift | 8 +++- .../POSReceiptControllerTests.swift | 48 +++++++++++++------ 8 files changed, 71 insertions(+), 26 deletions(-) diff --git a/Modules/Sources/Networking/Remote/ReceiptRemote.swift b/Modules/Sources/Networking/Remote/ReceiptRemote.swift index 01246007b02..4a657b43dd8 100644 --- a/Modules/Sources/Networking/Remote/ReceiptRemote.swift +++ b/Modules/Sources/Networking/Remote/ReceiptRemote.swift @@ -53,27 +53,31 @@ public final class ReceiptRemote: Remote { /// - Parameters: /// - siteID: Site which hosts the Order. /// - orderID: ID of the order that the receipt is associated to. - public func sendPOSReceipt(siteID: Int64, orderID: Int64) async throws { + public func sendPOSReceipt(siteID: Int64, orderID: Int64, emailAddress: String) async throws { let sendEmailPath = "\(Constants.ordersPath)/\(orderID)/\(Constants.actionsPath)/send_email" let sendEmailRequest = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: sendEmailPath, parameters: [ - ParameterKeys.templateID: POSConstants.receiptTemplateID + ParameterKeys.templateID: POSConstants.receiptTemplateID, + ParameterKeys.email: emailAddress, + ParameterKeys.forceEmailUpdate: true ], availableAsRESTRequest: true) try await enqueue(sendEmailRequest) } } -extension ReceiptRemote: POSReceiptsRemoteProtocol { } +extension ReceiptRemote: POSReceiptsRemoteProtocol {} private extension ReceiptRemote { enum ParameterKeys { static let expirationDays: String = "expiration_days" static let forceRegenerate: String = "force_new" static let templateID: String = "template_id" + static let forceEmailUpdate: String = "force_email_update" + static let email: String = "email" } enum Constants { diff --git a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift index 56c1bd0ae3f..b228c264a64 100644 --- a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift +++ b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift @@ -2,7 +2,7 @@ import Foundation public protocol POSReceiptsRemoteProtocol { func sendReceipt(siteID: Int64, orderID: Int64) async throws - func sendPOSReceipt(siteID: Int64, orderID: Int64) async throws + func sendPOSReceipt(siteID: Int64, orderID: Int64, emailAddress: String) async throws } public protocol POSOrdersRemoteProtocol { diff --git a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift index 436ef977833..3a9eaa0f3e4 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift @@ -28,7 +28,7 @@ public final class POSReceiptService: POSReceiptServiceProtocol { public func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws { do { if isEligibleForPOSReceipt { - try await receiptsRemote.sendPOSReceipt(siteID: siteID, orderID: orderID) + try await receiptsRemote.sendPOSReceipt(siteID: siteID, orderID: orderID, emailAddress: recipientEmail) } else { try await receiptsRemote.sendReceipt(siteID: siteID, orderID: orderID) } diff --git a/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift index 87bb6c14c1d..597f9ec48e5 100644 --- a/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/ReceiptRemoteTests.swift @@ -129,6 +129,7 @@ final class ReceiptRemoteTests: XCTestCase { // Given let remote = ReceiptRemote(network: network) let posReceiptTemplateID = "customer_pos_completed_order" + let testEmail = "test@example.com" network.simulateResponse( requestUrlSuffix: "orders/\(sampleOrderID)/actions/send_email", @@ -136,22 +137,25 @@ final class ReceiptRemoteTests: XCTestCase { ) // When - try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID) + try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID, emailAddress: testEmail) // Then the send email request was made with correct parameters. let sendEmailRequest = try XCTUnwrap(network.requestsForResponseData.last as? JetpackRequest) XCTAssertEqual(sendEmailRequest.method, .post) XCTAssertEqual(sendEmailRequest.path, "orders/\(sampleOrderID)/actions/send_email") XCTAssertEqual(sendEmailRequest.parameters["template_id"] as? String, posReceiptTemplateID) + XCTAssertEqual(sendEmailRequest.parameters["email"] as? String, testEmail) + XCTAssertEqual(sendEmailRequest.parameters["force_email_update"] as? Bool, true) } func test_sendPOSReceipt_when_no_reponse_exist_throws_error() async { // Given let remote = ReceiptRemote(network: network) + let testEmail = "test@example.com" await assertThrowsError({ // When - try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID) + try await remote.sendPOSReceipt(siteID: sampleSiteID, orderID: sampleOrderID, emailAddress: testEmail) }, errorAssert: { error in // Then return error is NetworkError diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift index 2e343acc729..e11dc6ba087 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift @@ -5,6 +5,7 @@ final class MockPOSReceiptsRemote: POSReceiptsRemoteProtocol { var sendPOSReceiptCalled = false var spySiteID: Int64? var spyOrderID: Int64? + var spyEmail: String? var shouldThrowError: Error? func sendReceipt(siteID: Int64, orderID: Int64) async throws { @@ -17,7 +18,14 @@ final class MockPOSReceiptsRemote: POSReceiptsRemoteProtocol { } } - func sendPOSReceipt(siteID: Int64, orderID: Int64) async throws { + func sendPOSReceipt(siteID: Int64, orderID: Int64, emailAddress: String) async throws { sendPOSReceiptCalled = true + spySiteID = siteID + spyOrderID = orderID + spyEmail = emailAddress + + if let shouldThrowError { + throw shouldThrowError + } } } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift index 2f54df923d8..ee627877d53 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift @@ -48,10 +48,17 @@ struct POSReceiptServiceTests { @Test func sendReceipt_calls_remote_when_isEligibleForPOSReceipt_is_true() async throws { + // Given + let email = "test@example.com" + let orderID: Int64 = 789 + // When - try await sut.sendReceipt(orderID: Order.fake().orderID, recipientEmail: "test@example.com", isEligibleForPOSReceipt: true) + try await sut.sendReceipt(orderID: orderID, recipientEmail: email, isEligibleForPOSReceipt: true) // Then #expect(receiptsRemote.sendPOSReceiptCalled) + #expect(receiptsRemote.spySiteID == 123) + #expect(receiptsRemote.spyOrderID == orderID) + #expect(receiptsRemote.spyEmail == email) } } diff --git a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift index 9fafd259219..8023e6809dc 100644 --- a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift +++ b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift @@ -40,8 +40,6 @@ final class POSReceiptController: POSReceiptControllerProtocol { func sendReceipt(orderID: Int64, recipientEmail: String) async throws { var isEligibleForPOSReceipt: Bool? do { - try await orderService.updatePOSOrder(orderID: orderID, recipientEmail: recipientEmail) - let posReceiptEligibility: Bool if featureFlagService.isFeatureFlagEnabled(.pointOfSaleReceipts) { posReceiptEligibility = isPluginSupported( @@ -54,6 +52,12 @@ final class POSReceiptController: POSReceiptControllerProtocol { } isEligibleForPOSReceipt = posReceiptEligibility + // Only update order email for previous POS receipt API version + // POS receipt now handles email update internally + if !posReceiptEligibility { + try await orderService.updatePOSOrder(orderID: orderID, recipientEmail: recipientEmail) + } + try await receiptService.sendReceipt(orderID: orderID, recipientEmail: recipientEmail, isEligibleForPOSReceipt: posReceiptEligibility) analytics.track(.receiptEmailSuccess, withProperties: ["eligible_for_pos_receipt": posReceiptEligibility]) diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift index e4b3c43ae6c..748bfc6becb 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift @@ -24,20 +24,6 @@ struct POSReceiptControllerTests { pluginsService: mockPluginsService) } - @Test func sendReceipt_calls_both_updateOrder_and_sendReceipt() async throws { - // Given - mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true - let order = Order.fake() - let recipientEmail = "test@fake.com" - - // When - try await sut.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) - - // Then - #expect(mockOrderService.updateOrderWasCalled) - #expect(mockReceiptService.sendReceiptWasCalled == true) - } - @Test func sendReceipt_tracks_success_with_eligible_for_pos_receipt() async throws { // Given mockPluginsService.setMockPlugin(.wooCommerce, @@ -70,7 +56,7 @@ struct POSReceiptControllerTests { #expect(Bool(false), "Expected error to be thrown") } catch { // Then - error was thrown as expected - #expect(mockOrderService.updateOrderWasCalled) + #expect(!mockOrderService.updateOrderWasCalled) // Should not update order for POS receipts even on failure } } @@ -196,4 +182,36 @@ struct POSReceiptControllerTests { static let ineligibleWCPluginVersions = ["9.9.0", "9.9.9", "9.9.9-beta.9", "9.9.9-dev"] } } + + @Test func sendReceipt_calls_sendReceipt_but_not_updateOrder_for_POS_receipts() async throws { + // Given + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true + mockPluginsService.setMockPlugin(.wooCommerce, + systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", + version: "10.0.0", + active: true)) + let order = Order.fake() + let recipientEmail = "test@fake.com" + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) + + // Then + #expect(!mockOrderService.updateOrderWasCalled) // Should not update order for POS receipts + #expect(mockReceiptService.sendReceiptWasCalled == true) + } + + @Test func sendReceipt_calls_both_updateOrder_and_sendReceipt_for_legacy_POS_receipts() async throws { + // Given - feature flag disabled or plugin not eligible + mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = false + let order = Order.fake() + let recipientEmail = "test@fake.com" + + // When + try await sut.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) + + // Then + #expect(mockOrderService.updateOrderWasCalled) // Should update order for traditional receipts + #expect(mockReceiptService.sendReceiptWasCalled == true) + } } From 76e692f033e7ee27a69cad1128c22c7172edec99 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:56:34 +0300 Subject: [PATCH 14/25] Remove unused code --- Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift | 1 - .../POS/Presentation/Reusable Views/POSSendReceiptView.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift index 255733e0e78..cddca87befa 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift @@ -94,7 +94,6 @@ private extension Order { private extension POSOrderService { enum POSOrderServiceError: Error { - case emailAlreadySet case updateOrderFailed } } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift index da13d0a9a57..904afcb6825 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift @@ -4,7 +4,6 @@ import WooFoundation import class WordPressShared.EmailFormatValidator struct POSSendReceiptView: View { - @Environment(\.dynamicTypeSize) var dynamicTypeSize @State private var textFieldInput: String = "" @State private var isLoading: Bool = false @State private var errorMessage: String? From 9b2d86bb68a8d7a296fa74319c555bb623fab4a7 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 10 Sep 2025 15:13:32 +0300 Subject: [PATCH 15/25] Only modify back button icon from POSPageHeaderView --- .../Orders/PointOfSaleOrderDetailsView.swift | 2 +- .../POSPageHeaderBackButton.swift | 3 +- .../Reusable Views/POSPageHeaderView.swift | 44 +++++++++---------- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index db56d6bb32a..a4dbfe49e5d 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -46,7 +46,7 @@ struct PointOfSaleOrderDetailsView: View { POSSendReceiptView(isShowingSendReceiptView: $isShowingEmailReceiptView) { email in try await orderListModel.sendReceipt(order: order, email: email) } - .posHeaderBackButton(.init(state: .enabled, action: { isShowingEmailReceiptView = false }, buttonIcon: "xmark")) + .posHeaderBackButtonIcon("xmark") } } } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift index d56b0c2b3d1..ae81cac56ac 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderBackButton.swift @@ -2,13 +2,14 @@ import SwiftUI struct POSPageHeaderBackButton: View { private let configuration: POSPageHeaderBackButtonConfiguration + @Environment(\.posHeaderBackButtonIcon) private var environmentIcon init(configuration: POSPageHeaderBackButtonConfiguration) { self.configuration = configuration } private var buttonIcon: String { - configuration.buttonIcon ?? Constants.defaultBackButtonIcon + environmentIcon ?? configuration.buttonIcon ?? Constants.defaultBackButtonIcon } var body: some View { diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift index 99a008005cb..ca491016fac 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift @@ -1,18 +1,5 @@ import SwiftUI -// MARK: - Environment Key for Header Back Button Configuration - -struct POSHeaderBackButtonConfigurationKey: EnvironmentKey { - static let defaultValue: POSPageHeaderBackButtonConfiguration? = nil -} - -extension EnvironmentValues { - var posHeaderBackButtonConfiguration: POSPageHeaderBackButtonConfiguration? { - get { self[POSHeaderBackButtonConfigurationKey.self] } - set { self[POSHeaderBackButtonConfigurationKey.self] = newValue } - } -} - /// Configuration for the back button in the header. struct POSPageHeaderBackButtonConfiguration { enum State { @@ -183,24 +170,35 @@ private enum Constants { static let titleSubtitleSpacing: CGFloat = POSSpacing.xSmall } -// MARK: - ViewModifier for Header Back Button Configuration -struct POSHeaderBackButtonModifier: ViewModifier { - let configuration: POSPageHeaderBackButtonConfiguration? +struct POSHeaderBackButtonConfigurationKey: EnvironmentKey { + static let defaultValue: POSPageHeaderBackButtonConfiguration? = nil +} + +struct POSHeaderBackButtonIconKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +extension EnvironmentValues { + var posHeaderBackButtonConfiguration: POSPageHeaderBackButtonConfiguration? { + get { self[POSHeaderBackButtonConfigurationKey.self] } + set { self[POSHeaderBackButtonConfigurationKey.self] = newValue } + } - func body(content: Content) -> some View { - content.environment(\.posHeaderBackButtonConfiguration, configuration) + var posHeaderBackButtonIcon: String? { + get { self[POSHeaderBackButtonIconKey.self] } + set { self[POSHeaderBackButtonIconKey.self] = newValue } } } // MARK: - View Extensions for Easy Usage extension View { - /// Applies a back button configuration to all POSPageHeaderView instances in the view hierarchy. - /// - Parameter configuration: The back button configuration to apply, or nil to disable - /// - Returns: A view with the environment configuration set - func posHeaderBackButton(_ configuration: POSPageHeaderBackButtonConfiguration?) -> some View { - modifier(POSHeaderBackButtonModifier(configuration: configuration)) + /// Sets the back button icon for all POSPageHeaderView instances in the view hierarchy. + /// - Parameter icon: The system name for the back button icon (e.g., "xmark") + /// - Returns: A view with the icon environment value set + func posHeaderBackButtonIcon(_ icon: String) -> some View { + environment(\.posHeaderBackButtonIcon, icon) } } From f259d644535a6ceddf1c21ff3c2ca06cc1099c42 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:39:37 +0300 Subject: [PATCH 16/25] Make POSReceiptControllerTests properties private --- .../POS/Controllers/POSReceiptControllerTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift index 748bfc6becb..756726d1014 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift @@ -8,12 +8,12 @@ import protocol WooFoundation.Analytics import enum Networking.DotcomError struct POSReceiptControllerTests { - let mockOrderService = MockPOSOrderService() - let mockReceiptService = MockReceiptService() - let mockAnalyticsProvider = MockAnalyticsProvider() - let mockFeatureFlagService = MockFeatureFlagService() - let mockPluginsService = MockPluginsService() - let sut: POSReceiptController + private let mockOrderService = MockPOSOrderService() + private let mockReceiptService = MockReceiptService() + private let mockAnalyticsProvider = MockAnalyticsProvider() + private let mockFeatureFlagService = MockFeatureFlagService() + private let mockPluginsService = MockPluginsService() + private let sut: POSReceiptController init() { self.sut = POSReceiptController(siteID: 123, From ac9435a9d5c4efa1a6857c7ea557991a9f095da1 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:39:47 +0300 Subject: [PATCH 17/25] Fix OrdersRemote formatting --- .../Sources/NetworkingCore/Remote/OrdersRemote.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift index 31855bda7f9..2d9cfea38b5 100644 --- a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift +++ b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift @@ -460,11 +460,11 @@ extension OrdersRemote: POSOrdersRemoteProtocol { let path = "\(Constants.ordersPath)/\(orderID)" let request = JetpackRequest(wooApiVersion: .mark3, - method: .post, - siteID: siteID, - path: path, - parameters: parameters, - availableAsRESTRequest: true) + method: .post, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true) try await enqueue(request) } From b16a96b91000d5b4cd4e00d8b09f4ef6dd772093 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:40:00 +0300 Subject: [PATCH 18/25] Fix POSOrderServiceTests formatting --- .../YosemiteTests/Tools/POS/POSOrderServiceTests.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift index 61701d99a63..e7e09015375 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSOrderServiceTests.swift @@ -167,8 +167,7 @@ struct POSOrderServiceTests { }) } - @Test - func updatePOSOrder_calls_remote_updatePOSOrderEmail_with_correct_parameters() async throws { + @Test func updatePOSOrder_calls_remote_updatePOSOrderEmail_with_correct_parameters() async throws { // Given let siteID: Int64 = 123 let orderID: Int64 = 456 @@ -184,8 +183,7 @@ struct POSOrderServiceTests { #expect(mockOrdersRemote.spyUpdatePOSOrderEmailAddress == recipientEmail) } - @Test - func updatePOSOrder_throws_error_when_remote_call_fails() async throws { + @Test func updatePOSOrder_throws_error_when_remote_call_fails() async throws { // Given mockOrdersRemote.updatePOSOrderEmailResult = .failure(NSError(domain: "", code: 0)) From 199d2f4085a48ee8843d2a6fc8dc88121dfeb769 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:40:16 +0300 Subject: [PATCH 19/25] Remove import Observation from POSReceiptController --- WooCommerce/Classes/POS/Controllers/POSReceiptController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift index 8023e6809dc..0fb1945d504 100644 --- a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift +++ b/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift @@ -1,5 +1,4 @@ import Foundation -import Observation import protocol Experiments.FeatureFlagService import protocol Yosemite.StoresManager import protocol Yosemite.POSOrderServiceProtocol From 95f5b262b175174d8795255c96dfe0ca51b69751 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:43:15 +0300 Subject: [PATCH 20/25] Name posHeaderBackButtonIcon with systemName --- .../Presentation/Orders/PointOfSaleOrderDetailsView.swift | 2 +- .../POS/Presentation/Reusable Views/POSPageHeaderView.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index a4dbfe49e5d..d6213e51885 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -46,7 +46,7 @@ struct PointOfSaleOrderDetailsView: View { POSSendReceiptView(isShowingSendReceiptView: $isShowingEmailReceiptView) { email in try await orderListModel.sendReceipt(order: order, email: email) } - .posHeaderBackButtonIcon("xmark") + .posHeaderBackButtonIcon(systemName: "xmark") } } } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift index ca491016fac..8628de52074 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift @@ -195,10 +195,10 @@ extension EnvironmentValues { extension View { /// Sets the back button icon for all POSPageHeaderView instances in the view hierarchy. - /// - Parameter icon: The system name for the back button icon (e.g., "xmark") + /// - Parameter systemName: The system name for the back button icon (e.g., "xmark") /// - Returns: A view with the icon environment value set - func posHeaderBackButtonIcon(_ icon: String) -> some View { - environment(\.posHeaderBackButtonIcon, icon) + func posHeaderBackButtonIcon(systemName: String) -> some View { + environment(\.posHeaderBackButtonIcon, systemName) } } From 4a397e76367a5a4cac5a9f702f3abe7fd25ea406 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:45:42 +0300 Subject: [PATCH 21/25] Make error throw from mocks more reusable --- .../POS/Mocks/MockPOSReceiptController.swift | 6 +++--- .../POS/Mocks/MockPointOfSaleOrderController.swift | 6 +++--- .../POS/Models/PointOfSaleAggregateModelTests.swift | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift index 64b353998e4..689df829fac 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift @@ -3,7 +3,7 @@ import Foundation import struct Yosemite.Order final class MockPOSReceiptController: POSReceiptControllerProtocol { - var shouldThrowReceiptError: Bool = false + var sendReceiptErrorToThrow: Error? var sendReceiptWasCalled: Bool = false var sendReceiptCalledWithOrderID: Int64? var sendReceiptCalledWithEmail: String? @@ -13,8 +13,8 @@ final class MockPOSReceiptController: POSReceiptControllerProtocol { sendReceiptCalledWithOrderID = orderID sendReceiptCalledWithEmail = recipientEmail - if shouldThrowReceiptError { - throw PointOfSaleOrderController.PointOfSaleOrderControllerError.noOrder + if let sendReceiptErrorToThrow { + throw sendReceiptErrorToThrow } } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift index cec2a69d654..33bb4bd1e30 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift @@ -37,12 +37,12 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol { clearOrderWasCalled = true } - var shouldThrowReceiptError: Bool = false + var sendReceiptErrorToThrow: Error? var sendReceiptWasCalled: Bool = false func sendReceipt(recipientEmail: String) async throws { sendReceiptWasCalled = true - if shouldThrowReceiptError { - throw NSError(domain: "some error", code: -1) + if let sendReceiptErrorToThrow { + throw sendReceiptErrorToThrow } } } diff --git a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift index 2410f5529ae..aa19da1792e 100644 --- a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift @@ -301,8 +301,8 @@ struct PointOfSaleAggregateModelTests { @Test func sendReceipt_when_invoked_with_error_then_returns_error() async throws { // Given let orderController = MockPointOfSaleOrderController() - orderController.shouldThrowReceiptError = true let expectedError = NSError(domain: "some error", code: -1) + orderController.sendReceiptErrorToThrow = expectedError let sut = makePointOfSaleAggregateModel(orderController: orderController) From 274c455dbffbb535ac028221338f84b8a3e62389 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 21:11:56 +0300 Subject: [PATCH 22/25] Rename POSReceiptController to POSReceiptSender --- .../PointOfSaleOrderController.swift | 8 +-- .../Models/PointOfSaleOrderListModel.swift | 8 +-- .../PointOfSaleEntryPointView.swift | 10 +-- .../POS/TabBar/POSTabCoordinator.swift | 6 +- .../POSReceiptSender.swift} | 8 +-- .../Classes/POS/Utils/PreviewHelpers.swift | 4 +- .../WooCommerce.xcodeproj/project.pbxproj | 16 ++--- .../PointOfSaleOrderControllerTests.swift | 66 +++++++++---------- .../POS/Mocks/MockPOSReceiptController.swift | 2 +- .../POSReceiptSenderTests.swift} | 14 ++-- 10 files changed, 71 insertions(+), 71 deletions(-) rename WooCommerce/Classes/POS/{Controllers/POSReceiptController.swift => Utils/POSReceiptSender.swift} (95%) rename WooCommerce/WooCommerceTests/POS/{Controllers/POSReceiptControllerTests.swift => Tools/POSReceiptSenderTests.swift} (97%) diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index 77ee1db7101..e3eb8f3a060 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -36,13 +36,13 @@ protocol PointOfSaleOrderControllerProtocol { @Observable final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol { init(orderService: POSOrderServiceProtocol, - receiptController: POSReceiptControllerProtocol, + receiptSender: POSReceiptSending, stores: StoresManager = ServiceLocator.stores, currencySettings: CurrencySettings = ServiceLocator.currencySettings, analytics: Analytics = ServiceLocator.analytics, celebration: PaymentCaptureCelebrationProtocol = PaymentCaptureCelebration()) { self.orderService = orderService - self.receiptController = receiptController + self.receiptSender = receiptSender self.stores = stores self.storeCurrency = currencySettings.currencyCode self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) @@ -51,7 +51,7 @@ protocol PointOfSaleOrderControllerProtocol { } private let orderService: POSOrderServiceProtocol - private let receiptController: POSReceiptControllerProtocol + private let receiptSender: POSReceiptSending private let currencyFormatter: CurrencyFormatter private let celebration: PaymentCaptureCelebrationProtocol @@ -103,7 +103,7 @@ protocol PointOfSaleOrderControllerProtocol { throw PointOfSaleOrderControllerError.noOrder } - try await receiptController.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) + try await receiptSender.sendReceipt(orderID: order.orderID, recipientEmail: recipientEmail) } func clearOrder() { diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift index fc528a860b0..af4d94e7bc7 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift @@ -4,15 +4,15 @@ import struct Yosemite.POSOrder @Observable final class PointOfSaleOrderListModel { let ordersController: PointOfSaleSearchingOrderListControllerProtocol - let receiptController: POSReceiptControllerProtocol + let receiptSender: POSReceiptSending init(ordersController: PointOfSaleSearchingOrderListControllerProtocol, - receiptController: POSReceiptControllerProtocol) { + receiptSender: POSReceiptSending) { self.ordersController = ordersController - self.receiptController = receiptController + self.receiptSender = receiptSender } func sendReceipt(order: POSOrder, email: String) async throws { - try await receiptController.sendReceipt(orderID: order.id, recipientEmail: email) + try await receiptSender.sendReceipt(orderID: order.id, recipientEmail: email) } } diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index c3951df0307..bb3ef16d7d9 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -16,7 +16,7 @@ struct PointOfSaleEntryPointView: View { private let couponsController: PointOfSaleCouponsControllerProtocol private let couponsSearchController: PointOfSaleSearchingItemsControllerProtocol private let ordersController: PointOfSaleSearchingOrderListControllerProtocol - private let receiptController: POSReceiptControllerProtocol + private let receiptSender: POSReceiptSending private let cardPresentPaymentService: CardPresentPaymentFacade private let orderController: PointOfSaleOrderControllerProtocol private let settingsController: PointOfSaleSettingsControllerProtocol @@ -33,7 +33,7 @@ struct PointOfSaleEntryPointView: View { onPointOfSaleModeActiveStateChange: @escaping ((Bool) -> Void), cardPresentPaymentService: CardPresentPaymentFacade, orderController: PointOfSaleOrderControllerProtocol, - receiptController: POSReceiptControllerProtocol, + receiptSender: POSReceiptSending, settingsController: PointOfSaleSettingsControllerProtocol, collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking, searchHistoryService: POSSearchHistoryProviding, @@ -48,7 +48,7 @@ struct PointOfSaleEntryPointView: View { self.couponsSearchController = couponsSearchController self.cardPresentPaymentService = cardPresentPaymentService self.orderController = orderController - self.receiptController = receiptController + self.receiptSender = receiptSender self.settingsController = settingsController self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService @@ -88,7 +88,7 @@ struct PointOfSaleEntryPointView: View { .environmentObject(posModalManager) .environmentObject(posSheetManager) .environmentObject(posCoverManager) - .environment(PointOfSaleOrderListModel(ordersController: ordersController, receiptController: receiptController)) + .environment(PointOfSaleOrderListModel(ordersController: ordersController, receiptSender: receiptSender)) .injectKeyboardObserver() .onAppear { onPointOfSaleModeActiveStateChange(true) @@ -111,7 +111,7 @@ struct PointOfSaleEntryPointView: View { onPointOfSaleModeActiveStateChange: { _ in }, cardPresentPaymentService: CardPresentPaymentPreviewService(), orderController: PointOfSalePreviewOrderController(), - receiptController: POSReceiptControllerPreview(), + receiptSender: POSReceiptSenderPreview(), settingsController: PointOfSaleSettingsPreviewController(), collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentPreviewAnalytics(), searchHistoryService: PointOfSalePreviewHistoryService(), diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 3c4f62f1f58..3f778cdc99b 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -103,7 +103,7 @@ private extension POSTabCoordinator { let orderService = POSOrderService(siteID: siteID, credentials: credentials), #available(iOS 17.0, *) { - let receiptController = POSReceiptController(siteID: siteID, + let receiptSender = POSReceiptSender(siteID: siteID, orderService: orderService, receiptService: receiptService) let posView = PointOfSaleEntryPointView( @@ -134,8 +134,8 @@ private extension POSTabCoordinator { }, cardPresentPaymentService: cardPresentPaymentService, orderController: PointOfSaleOrderController(orderService: orderService, - receiptController: receiptController), - receiptController: receiptController, + receiptSender: receiptSender), + receiptSender: receiptSender, settingsController: PointOfSaleSettingsController(siteID: siteID, settingsService: settingsService, cardPresentPaymentService: cardPresentPaymentService, diff --git a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift b/WooCommerce/Classes/POS/Utils/POSReceiptSender.swift similarity index 95% rename from WooCommerce/Classes/POS/Controllers/POSReceiptController.swift rename to WooCommerce/Classes/POS/Utils/POSReceiptSender.swift index 0fb1945d504..e9aac43b82b 100644 --- a/WooCommerce/Classes/POS/Controllers/POSReceiptController.swift +++ b/WooCommerce/Classes/POS/Utils/POSReceiptSender.swift @@ -9,11 +9,11 @@ import enum Yosemite.Plugin import protocol WooFoundation.Analytics import class Yosemite.PluginsService -protocol POSReceiptControllerProtocol { +protocol POSReceiptSending { func sendReceipt(orderID: Int64, recipientEmail: String) async throws } -final class POSReceiptController: POSReceiptControllerProtocol { +final class POSReceiptSender: POSReceiptSending { init(siteID: Int64, orderService: POSOrderServiceProtocol, receiptService: POSReceiptServiceProtocol, @@ -70,7 +70,7 @@ final class POSReceiptController: POSReceiptControllerProtocol { } } -private extension POSReceiptController { +private extension POSReceiptSender { @MainActor func isPluginSupported(_ plugin: Plugin, minimumVersion: String, siteID: Int64) -> Bool { // Plugin must be installed and active @@ -87,7 +87,7 @@ private extension POSReceiptController { } } -private extension POSReceiptController { +private extension POSReceiptSender { enum POSReceiptEligibilityConstants { static let wcPluginMinimumVersion = "10.0.0" } diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index 650ea9fb83d..1a4ca90336d 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -238,7 +238,7 @@ struct POSPreviewHelpers { } static func makePreviewOrdersModel() -> PointOfSaleOrderListModel { - return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController(), receiptController: POSReceiptControllerPreview()) + return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController(), receiptSender: POSReceiptSenderPreview()) } static func makePreviewOrder() -> POSOrder { @@ -392,7 +392,7 @@ final class PointOfSalePreviewBarcodeScanService: PointOfSaleBarcodeScanServiceP } } -final class POSReceiptControllerPreview: POSReceiptControllerProtocol { +final class POSReceiptSenderPreview: POSReceiptSending { func sendReceipt(orderID: Int64, recipientEmail: String) async throws {} } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 0d3ddefca4c..3e8677a4357 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -75,8 +75,8 @@ 019130212CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */; }; 01929C342CEF6354006C79ED /* CardPresentModalErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */; }; 01929C362CEF6D6E006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */; }; - 019460DE2E700DF800FCB9AB /* POSReceiptController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DD2E700DF800FCB9AB /* POSReceiptController.swift */; }; - 019460E02E700E3D00FCB9AB /* POSReceiptControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DF2E700E3D00FCB9AB /* POSReceiptControllerTests.swift */; }; + 019460DE2E700DF800FCB9AB /* POSReceiptSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DD2E700DF800FCB9AB /* POSReceiptSender.swift */; }; + 019460E02E700E3D00FCB9AB /* POSReceiptSenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460DF2E700E3D00FCB9AB /* POSReceiptSenderTests.swift */; }; 019460E22E70121A00FCB9AB /* MockPOSReceiptController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */; }; 019630B42D01DB4800219D80 /* TapToPayAwarenessMomentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */; }; 019630B62D02018C00219D80 /* TapToPayAwarenessMomentDeterminer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */; }; @@ -3295,8 +3295,8 @@ 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationViewModelTests.swift; sourceTree = ""; }; 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorWithoutEmail.swift; sourceTree = ""; }; 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalNonRetryableErrorWithoutEmail.swift; sourceTree = ""; }; - 019460DD2E700DF800FCB9AB /* POSReceiptController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptController.swift; sourceTree = ""; }; - 019460DF2E700E3D00FCB9AB /* POSReceiptControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptControllerTests.swift; sourceTree = ""; }; + 019460DD2E700DF800FCB9AB /* POSReceiptSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptSender.swift; sourceTree = ""; }; + 019460DF2E700E3D00FCB9AB /* POSReceiptSenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptSenderTests.swift; sourceTree = ""; }; 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSReceiptController.swift; sourceTree = ""; }; 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentView.swift; sourceTree = ""; }; 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentDeterminer.swift; sourceTree = ""; }; @@ -7209,6 +7209,7 @@ 026826972BF59D9E0036F959 /* Utils */ = { isa = PBXGroup; children = ( + 019460DD2E700DF800FCB9AB /* POSReceiptSender.swift */, 01806E122E2F7F400033363C /* POSBrightnessControl.swift */, 68E33B2D2E66AAAE00CBE921 /* POSConstants.swift */, 689F29192DE4557D004DF52B /* POSStockFormatter.swift */, @@ -8227,7 +8228,6 @@ 200BA1572CF092150006DC5B /* Controllers */ = { isa = PBXGroup; children = ( - 019460DD2E700DF800FCB9AB /* POSReceiptController.swift */, 02E4E7452E0EF847003A31E7 /* POSEntryPointController.swift */, 68B681152D92577F0098D5CD /* PointOfSaleCouponsController.swift */, 200BA1582CF092280006DC5B /* PointOfSaleItemsController.swift */, @@ -8240,7 +8240,6 @@ 200BA15C2CF0A9D90006DC5B /* Controllers */ = { isa = PBXGroup; children = ( - 019460DF2E700E3D00FCB9AB /* POSReceiptControllerTests.swift */, 6818E7C02D93C76200677C16 /* PointOfSaleCouponsControllerTests.swift */, 20DB185C2CF5E7560018D3E1 /* PointOfSaleOrderControllerTests.swift */, 68D7480F2E5DB6D20048CFE9 /* PointOfSaleSettingsControllerTests.swift */, @@ -8398,6 +8397,7 @@ isa = PBXGroup; children = ( 20A130EA2C5A27190058022F /* PointOfSaleAssetsTests.swift */, + 019460DF2E700E3D00FCB9AB /* POSReceiptSenderTests.swift */, ); path = Tools; sourceTree = ""; @@ -16644,7 +16644,7 @@ DE6906E327D7121800735E3B /* GhostTableViewController.swift in Sources */, 02EEB5C42424AFAA00B8A701 /* TextFieldTableViewCell.swift in Sources */, 453326FD2C3C5315000E4862 /* ProductCreationAIPromptProgressBarViewModel.swift in Sources */, - 019460DE2E700DF800FCB9AB /* POSReceiptController.swift in Sources */, + 019460DE2E700DF800FCB9AB /* POSReceiptSender.swift in Sources */, 26E7EE6A292D688900793045 /* AnalyticsHubViewModel.swift in Sources */, AE457813275644590092F687 /* OrderStatusSection.swift in Sources */, B57C744E20F56E3800EEFC87 /* UITableViewCell+Helpers.swift in Sources */, @@ -17744,7 +17744,7 @@ 02077F72253816FF005A78EF /* ProductFormActionsFactory+ReadonlyProductTests.swift in Sources */, D8C11A6222E24C4A00D4A88D /* LedgerTableViewCellTests.swift in Sources */, 027111422913B9FC00F5269A /* AccountCreationFormViewModelTests.swift in Sources */, - 019460E02E700E3D00FCB9AB /* POSReceiptControllerTests.swift in Sources */, + 019460E02E700E3D00FCB9AB /* POSReceiptSenderTests.swift in Sources */, 2084B7A82C776E1000EFBD2E /* PointOfSaleCardPresentPaymentFoundMultipleReadersAlertViewModelTests.swift in Sources */, 02B8E41B2DFBC33D001D01FD /* MockPOSEligibilityChecker.swift in Sources */, DE50295328BF4A8A00551736 /* JetpackConnectionWebViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index 4522136353e..14d94bc3681 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -16,12 +16,12 @@ import enum Networking.NetworkError struct PointOfSaleOrderControllerTests { let mockOrderService = MockPOSOrderService() - let mockReceiptController = MockPOSReceiptController() + let mockReceiptSender = MockPOSReceiptSender() @Test func syncOrder_without_items_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) // When await sut.syncOrder(for: Cart(), retryHandler: {}) @@ -33,7 +33,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_cart_matching_order_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) let cartItem = makeItem(orderItemsToMatch: [orderItem]) @@ -52,7 +52,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_already_syncing_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) mockOrderService.simulateSyncing = true Task { await sut.syncOrder(for: Cart(purchasableItems: [makeItem(quantity: 1)]), retryHandler: {}) @@ -77,7 +77,7 @@ struct PointOfSaleOrderControllerTests { decimalSeparator: ".", numberOfDecimals: 2) let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController, + receiptSender: mockReceiptSender, currencySettings: currencySettings) // When @@ -90,7 +90,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_changes_from_previous_order_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let cartItem = makeItem(quantity: 1) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) @@ -110,7 +110,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_no_previous_order_sets_orderState_syncing_then_loaded() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let fakeOrder = Order.fake() mockOrderService.orderToReturn = fakeOrder var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] @@ -147,7 +147,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_order_sync_failure_sets_orderState_syncing_then_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) mockOrderService.orderToReturn = nil var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] @@ -183,7 +183,7 @@ struct PointOfSaleOrderControllerTests { @Test func sendReceipt_when_there_is_no_order_then_throws_noOrder_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let email = "test@example.com" // When @@ -192,14 +192,14 @@ struct PointOfSaleOrderControllerTests { } catch { // Then #expect(error as? PointOfSaleOrderController.PointOfSaleOrderControllerError == .noOrder) - #expect(!mockReceiptController.sendReceiptWasCalled) + #expect(!mockReceiptSender.sendReceiptWasCalled) } } - @Test func sendReceipt_with_order_delegates_to_receiptController() async throws { + @Test func sendReceipt_with_order_delegates_to_receiptSender() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let order = Order.fake() let recipientEmail = "test@fake.com" mockOrderService.orderToReturn = order @@ -211,16 +211,16 @@ struct PointOfSaleOrderControllerTests { try await sut.sendReceipt(recipientEmail: recipientEmail) // Then - #expect(mockReceiptController.sendReceiptWasCalled) - #expect(mockReceiptController.sendReceiptCalledWithOrderID == order.orderID) - #expect(mockReceiptController.sendReceiptCalledWithEmail == recipientEmail) + #expect(mockReceiptSender.sendReceiptWasCalled) + #expect(mockReceiptSender.sendReceiptCalledWithOrderID == order.orderID) + #expect(mockReceiptSender.sendReceiptCalledWithEmail == recipientEmail) } @Test func collectCashPayment_when_no_order_then_fails_with_noOrder_error() async throws { do { // Given/When let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController, + receiptSender: mockReceiptSender, celebration: MockPaymentCaptureCelebration()) try await sut.collectCashPayment(changeDueAmount: nil) } catch let error as PointOfSaleOrderController.PointOfSaleOrderControllerError { @@ -234,7 +234,7 @@ struct PointOfSaleOrderControllerTests { // Given let mockPaymentCelebration = MockPaymentCaptureCelebration() let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController, + receiptSender: mockReceiptSender, celebration: mockPaymentCelebration) let orderItem = OrderItem.fake() @@ -254,7 +254,7 @@ struct PointOfSaleOrderControllerTests { @Test func collectCashPayment_passes_changeDueAmount_to_order_service() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController, + receiptSender: mockReceiptSender, celebration: MockPaymentCaptureCelebration()) let orderItem = OrderItem.fake() @@ -274,7 +274,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_successful_returns_newOrder_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let fakeOrderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake() let fakeCartItem = makeItem(orderItemsToMatch: [fakeOrderItem]) @@ -294,7 +294,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_updating_existing_order_returns_newOrder_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let fakeOrder = Order.fake() mockOrderService.orderToReturn = fakeOrder @@ -316,7 +316,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_cart_matching_order_then_returns_orderNotChanged_result() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake().copy(items: [orderItem]) let cartItem = makeItem(orderItemsToMatch: [orderItem]) @@ -339,7 +339,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_then_returns_syncOrderState_failure() async throws { let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let cartItem = makeItem(quantity: 1) // When @@ -357,7 +357,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_cart_matching_order_and_coupons_doesnt_call_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let couponCode = "SAVE10" let coupon = OrderCouponLine.fake().copy(code: couponCode) @@ -380,7 +380,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_matching_items_but_different_coupons_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let initialCouponCode = "SAVE10" let initialCoupon = OrderCouponLine.fake().copy(code: initialCouponCode) @@ -403,7 +403,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_with_matching_items_but_removed_coupon_calls_orderService() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let orderItem = OrderItem.fake().copy(quantity: 1) let couponCode = "SAVE10" let coupon = OrderCouponLine.fake().copy(code: couponCode) @@ -426,7 +426,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_with_couponsError_then_sets_invalidCoupon_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let errorMessage = "Invalid coupon code" mockOrderService.errorToReturn = DotcomError.unknown(code: "woocommerce_rest_invalid_coupon", message: errorMessage) @@ -465,7 +465,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_orderService_fails_with_networkError_containing_couponsError_then_sets_invalidCoupon_error() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) let errorMessage = "Coupon INVALID does not exist" let errorJSON = """ { @@ -511,7 +511,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_fails_sets_order_to_nil() async throws { // Given let sut = PointOfSaleOrderController(orderService: mockOrderService, - receiptController: mockReceiptController) + receiptSender: mockReceiptSender) // First create a successful order let orderItem = OrderItem.fake().copy(quantity: 1) @@ -557,8 +557,8 @@ struct PointOfSaleOrderControllerTests { private let analytics: WooAnalytics private let analyticsProvider = MockAnalyticsProvider() private let orderService = MockPOSOrderService() - private let receiptService = MockReceiptService() - private let mockReceiptController = MockPOSReceiptController() + private let receiptSender = MockReceiptService() + private let mockReceiptSender = MockPOSReceiptSender() init() { analytics = WooAnalytics(analyticsProvider: analyticsProvider) @@ -567,7 +567,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_create_order_then_tracks_order_creation_success_event() async throws { // Given let sut = PointOfSaleOrderController(orderService: orderService, - receiptController: mockReceiptController, + receiptSender: mockReceiptSender, analytics: analytics) let fakeOrderItem = OrderItem.fake().copy(quantity: 1) let fakeOrder = Order.fake() @@ -584,7 +584,7 @@ struct PointOfSaleOrderControllerTests { @Test func syncOrder_when_create_order_fails_with_order_service_error_then_tracks_order_creation_failure_event() async throws { // Given let sut = PointOfSaleOrderController(orderService: orderService, - receiptController: mockReceiptController, + receiptSender: mockReceiptSender, analytics: analytics) orderService.orderToReturn = nil @@ -602,7 +602,7 @@ struct PointOfSaleOrderControllerTests { let mockAnalytics = WooAnalytics(analyticsProvider: mockAnalyticsProvider) let sut = PointOfSaleOrderController(orderService: orderService, - receiptController: mockReceiptController, + receiptSender: mockReceiptSender, analytics: mockAnalytics, celebration: MockPaymentCaptureCelebration()) diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift index 689df829fac..87dbc88dec2 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSReceiptController.swift @@ -2,7 +2,7 @@ import Foundation @testable import WooCommerce import struct Yosemite.Order -final class MockPOSReceiptController: POSReceiptControllerProtocol { +final class MockPOSReceiptSender: POSReceiptSending { var sendReceiptErrorToThrow: Error? var sendReceiptWasCalled: Bool = false var sendReceiptCalledWithOrderID: Int64? diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Tools/POSReceiptSenderTests.swift similarity index 97% rename from WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift rename to WooCommerce/WooCommerceTests/POS/Tools/POSReceiptSenderTests.swift index 756726d1014..969320fab62 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/POSReceiptControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Tools/POSReceiptSenderTests.swift @@ -7,16 +7,16 @@ import struct Yosemite.SystemPlugin import protocol WooFoundation.Analytics import enum Networking.DotcomError -struct POSReceiptControllerTests { +struct POSReceiptSenderTests { private let mockOrderService = MockPOSOrderService() private let mockReceiptService = MockReceiptService() private let mockAnalyticsProvider = MockAnalyticsProvider() private let mockFeatureFlagService = MockFeatureFlagService() private let mockPluginsService = MockPluginsService() - private let sut: POSReceiptController + private let sut: POSReceiptSender init() { - self.sut = POSReceiptController(siteID: 123, + self.sut = POSReceiptSender(siteID: 123, orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), @@ -75,7 +75,7 @@ struct POSReceiptControllerTests { systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", version: wcPluginVersion, active: true)) - let sut = POSReceiptController(siteID: 123, + let sut = POSReceiptSender(siteID: 123, orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), @@ -106,7 +106,7 @@ struct POSReceiptControllerTests { systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", version: wcPluginVersion, active: true)) - let sut = POSReceiptController(siteID: 123, + let sut = POSReceiptSender(siteID: 123, orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), @@ -133,7 +133,7 @@ struct POSReceiptControllerTests { systemPlugin: SystemPlugin.fake().copy(plugin: "woocommerce/woocommerce.php", version: wcPluginVersion, active: true)) - let sut = POSReceiptController(siteID: 123, + let sut = POSReceiptSender(siteID: 123, orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), @@ -161,7 +161,7 @@ struct POSReceiptControllerTests { let mockPluginsService = MockPluginsService() mockFeatureFlagService.isFeatureFlagEnabledReturnValue[.pointOfSaleReceipts] = true mockPluginsService.setMockPlugin(.wooCommerce, systemPlugin: plugin) - let sut = POSReceiptController(siteID: 123, + let sut = POSReceiptSender(siteID: 123, orderService: mockOrderService, receiptService: mockReceiptService, analytics: MockAnalytics(), From 9504721ad6b4fd4a9064184b845409c977bbaa8a Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 21:28:33 +0300 Subject: [PATCH 23/25] Use site timezone for order date formatting --- .../Orders/PointOfSaleOrderDetailsView.swift | 9 ++++++++- .../Classes/POS/TabBar/POSTabCoordinator.swift | 3 +++ .../Classes/POS/Utils/POSEnvironmentKeys.swift | 13 +++++++++++++ WooCommerce/WooCommerce.xcodeproj/project.pbxproj | 4 ++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index d6213e51885..b5465a90131 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -10,12 +10,19 @@ struct PointOfSaleOrderDetailsView: View { let onBack: () -> Void @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.siteTimezone) private var siteTimezone @Environment(PointOfSaleOrderListModel.self) private var orderListModel @State private var isShowingEmailReceiptView: Bool = false private var shouldShowBackButton: Bool { horizontalSizeClass == .compact } + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter.dateAndTimeFormatter + formatter.timeZone = siteTimezone + return formatter + } var body: some View { VStack(spacing: 0) { @@ -105,7 +112,7 @@ private extension PointOfSaleOrderDetailsView { @ViewBuilder func headerBottomContent(for order: POSOrder) -> some View { VStack(alignment: .leading, spacing: POSSpacing.xSmall) { - Text(DateFormatter.dateAndTimeFormatter.string(from: order.dateCreated)) + Text(dateFormatter.string(from: order.dateCreated)) .font(.posBodySmallRegular()) .foregroundStyle(Color.posOnSurfaceVariantHighest) .fixedSize(horizontal: false, vertical: true) diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 3f778cdc99b..448738376d3 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -97,6 +97,7 @@ private extension POSTabCoordinator { credentials: credentials, storage: storageManager) let pluginsService = PluginsService(storageManager: storageManager) + let siteTimezone = storesManager.sessionManager.defaultSite?.siteTimezone ?? .current if let receiptService = POSReceiptService(siteID: siteID, credentials: credentials), @@ -149,6 +150,8 @@ private extension POSTabCoordinator { barcodeScanService: barcodeScanService, posEligibilityChecker: eligibilityChecker ) + .environment(\.siteTimezone, siteTimezone) + let hostingController = UIHostingController(rootView: posView) hostingController.modalPresentationStyle = .fullScreen viewControllerToPresent.present(hostingController, animated: true) diff --git a/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift b/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift new file mode 100644 index 00000000000..df3a9d31f05 --- /dev/null +++ b/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftUI + +struct SiteTimezoneKey: EnvironmentKey { + static let defaultValue: TimeZone = .current +} + +extension EnvironmentValues { + var siteTimezone: TimeZone { + get { self[SiteTimezoneKey.self] } + set { self[SiteTimezoneKey.self] = newValue } + } +} \ No newline at end of file diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 3e8677a4357..3de330f8a68 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 015456CE2DB0341D0071C3C4 /* POSPageHeaderActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015456CD2DB033FF0071C3C4 /* POSPageHeaderActionButton.swift */; }; 0157A9962C4FEA7200866FFD /* PointOfSaleLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0157A9952C4FEA7200866FFD /* PointOfSaleLoadingView.swift */; }; 015D99AA2C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */; }; + 0161EFE22E734B2B006F27B4 /* POSEnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0161EFE12E734B2B006F27B4 /* POSEnvironmentKeys.swift */; }; 01620C4E2C5394B200D3EA2F /* POSProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */; }; 01664F9E2C50E685007CB5DD /* POSFontStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01664F9D2C50E685007CB5DD /* POSFontStyle.swift */; }; 016910982E1D019500B731DA /* GameControllerBarcodeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016910972E1D019500B731DA /* GameControllerBarcodeObserver.swift */; }; @@ -3270,6 +3271,7 @@ 015456CD2DB033FF0071C3C4 /* POSPageHeaderActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSPageHeaderActionButton.swift; sourceTree = ""; }; 0157A9952C4FEA7200866FFD /* PointOfSaleLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleLoadingView.swift; sourceTree = ""; }; 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentLayout.swift; sourceTree = ""; }; + 0161EFE12E734B2B006F27B4 /* POSEnvironmentKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSEnvironmentKeys.swift; sourceTree = ""; }; 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSProgressViewStyle.swift; sourceTree = ""; }; 01664F9D2C50E685007CB5DD /* POSFontStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFontStyle.swift; sourceTree = ""; }; 016910972E1D019500B731DA /* GameControllerBarcodeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerBarcodeObserver.swift; sourceTree = ""; }; @@ -7209,6 +7211,7 @@ 026826972BF59D9E0036F959 /* Utils */ = { isa = PBXGroup; children = ( + 0161EFE12E734B2B006F27B4 /* POSEnvironmentKeys.swift */, 019460DD2E700DF800FCB9AB /* POSReceiptSender.swift */, 01806E122E2F7F400033363C /* POSBrightnessControl.swift */, 68E33B2D2E66AAAE00CBE921 /* POSConstants.swift */, @@ -15791,6 +15794,7 @@ E1E636BB26FB467A00C9D0D7 /* Comparable+Woo.swift in Sources */, CE315DC42CC91A4A00A06748 /* WooShippingServiceViewModel.swift in Sources */, 450C2CB024CF006A00D570DD /* ProductTagsDataSource.swift in Sources */, + 0161EFE22E734B2B006F27B4 /* POSEnvironmentKeys.swift in Sources */, 0139BB522D91B45800C78FDE /* CouponRowView.swift in Sources */, DEB3879E2C34FE620025256E /* GoogleAdsCampaignCoordinator.swift in Sources */, EE45E2BA2A409BA40085F227 /* TooltipPresenter.swift in Sources */, From 9c6c91e3308f1f3e6f1259acaee132e0ce945507 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 21:49:06 +0300 Subject: [PATCH 24/25] Hold orderListModel in PointOfSaleEntryPointView as aggregateModel --- .../Presentation/Orders/PointOfSaleOrderDetailsView.swift | 2 +- .../POS/Presentation/PointOfSaleEntryPointView.swift | 8 +++----- WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index b5465a90131..51fdca001fe 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -17,7 +17,7 @@ struct PointOfSaleOrderDetailsView: View { private var shouldShowBackButton: Bool { horizontalSizeClass == .compact } - + private var dateFormatter: DateFormatter { let formatter = DateFormatter.dateAndTimeFormatter formatter.timeZone = siteTimezone diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index bb3ef16d7d9..2b523d1d720 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -7,6 +7,7 @@ struct PointOfSaleEntryPointView: View { @StateObject private var posModalManager = POSModalManager() @StateObject private var posSheetManager = POSSheetManager() @StateObject private var posCoverManager = POSFullScreenCoverManager() + @State private var orderListModel: PointOfSaleOrderListModel @State private var posEntryPointController: POSEntryPointController @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -15,8 +16,6 @@ struct PointOfSaleEntryPointView: View { private let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol private let couponsController: PointOfSaleCouponsControllerProtocol private let couponsSearchController: PointOfSaleSearchingItemsControllerProtocol - private let ordersController: PointOfSaleSearchingOrderListControllerProtocol - private let receiptSender: POSReceiptSending private let cardPresentPaymentService: CardPresentPaymentFacade private let orderController: PointOfSaleOrderControllerProtocol private let settingsController: PointOfSaleSettingsControllerProtocol @@ -48,14 +47,13 @@ struct PointOfSaleEntryPointView: View { self.couponsSearchController = couponsSearchController self.cardPresentPaymentService = cardPresentPaymentService self.orderController = orderController - self.receiptSender = receiptSender self.settingsController = settingsController self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker self.searchHistoryService = searchHistoryService self.popularPurchasableItemsController = popularPurchasableItemsController - self.ordersController = ordersController self.barcodeScanService = barcodeScanService self.posEntryPointController = POSEntryPointController(eligibilityChecker: posEligibilityChecker) + self.orderListModel = PointOfSaleOrderListModel(ordersController: ordersController, receiptSender: receiptSender) } var body: some View { @@ -88,7 +86,7 @@ struct PointOfSaleEntryPointView: View { .environmentObject(posModalManager) .environmentObject(posSheetManager) .environmentObject(posCoverManager) - .environment(PointOfSaleOrderListModel(ordersController: ordersController, receiptSender: receiptSender)) + .environment(orderListModel) .injectKeyboardObserver() .onAppear { onPointOfSaleModeActiveStateChange(true) diff --git a/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift b/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift index df3a9d31f05..932bfea871a 100644 --- a/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift +++ b/WooCommerce/Classes/POS/Utils/POSEnvironmentKeys.swift @@ -10,4 +10,4 @@ extension EnvironmentValues { get { self[SiteTimezoneKey.self] } set { self[SiteTimezoneKey.self] = newValue } } -} \ No newline at end of file +} From 4ae1b3c8bb45acd2a0070796f267bbfbc37d7ae4 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 11 Sep 2025 23:08:45 +0300 Subject: [PATCH 25/25] Update PointOfSaleEntryPointView.swift --- .../POS/Presentation/PointOfSaleEntryPointView.swift | 6 +++++- WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index 2b523d1d720..dca2d462486 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -23,6 +23,7 @@ struct PointOfSaleEntryPointView: View { private let searchHistoryService: POSSearchHistoryProviding private let popularPurchasableItemsController: PointOfSaleItemsControllerProtocol private let barcodeScanService: PointOfSaleBarcodeScanServiceProtocol + private let siteTimezone: TimeZone init(itemsController: PointOfSaleItemsControllerProtocol, purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol, @@ -38,7 +39,8 @@ struct PointOfSaleEntryPointView: View { searchHistoryService: POSSearchHistoryProviding, popularPurchasableItemsController: PointOfSaleItemsControllerProtocol, barcodeScanService: PointOfSaleBarcodeScanServiceProtocol, - posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol) { + posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol, + siteTimezone: TimeZone = .current) { self.onPointOfSaleModeActiveStateChange = onPointOfSaleModeActiveStateChange self.itemsController = itemsController @@ -54,6 +56,7 @@ struct PointOfSaleEntryPointView: View { self.barcodeScanService = barcodeScanService self.posEntryPointController = POSEntryPointController(eligibilityChecker: posEligibilityChecker) self.orderListModel = PointOfSaleOrderListModel(ordersController: ordersController, receiptSender: receiptSender) + self.siteTimezone = siteTimezone } var body: some View { @@ -87,6 +90,7 @@ struct PointOfSaleEntryPointView: View { .environmentObject(posSheetManager) .environmentObject(posCoverManager) .environment(orderListModel) + .environment(\.siteTimezone, siteTimezone) .injectKeyboardObserver() .onAppear { onPointOfSaleModeActiveStateChange(true) diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 448738376d3..f22a57e4016 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -148,9 +148,9 @@ private extension POSTabCoordinator { itemFetchStrategyFactory: posPopularItemFetchStrategyFactory ), barcodeScanService: barcodeScanService, - posEligibilityChecker: eligibilityChecker + posEligibilityChecker: eligibilityChecker, + siteTimezone: siteTimezone ) - .environment(\.siteTimezone, siteTimezone) let hostingController = UIHostingController(rootView: posView) hostingController.modalPresentationStyle = .fullScreen