Skip to content

Conversation

staskus
Copy link
Contributor

@staskus staskus commented Sep 9, 2025

WOOMOB-1140

Description

Add Email Receipt action to completed orders:

  • Update PointOfSaleOrderDetailsView with a section that has a button "Email receipt" when order status is .completed.
  • Present existing POSSendReceiptView when button is tapped.
  • Make POSSendReceiptView more reusable by injecting receipt sending action.
  • Added POSPageHeaderView ViewModifier to modify back button from outside.
  • Make POS receipt-sending reusable by extract it from OrderController into POSReceiptController which allows to pass any order ID.
  • Optimize existing order receipt services by only requiring Order ID and email address, not the whole Order object.
  • Optimize receipt-sending when using send-email API endpoint to utilize force_email_update parameter and not require updating order separately.

There are enough refactoring in this PR, I'm leaving Order List and Order Details update after the email is sent to another PR:

Steps to reproduce

  1. Open POS
  2. Menu -> Orders
  3. Click through orders
  4. Confirm Completed orders have Email receipt button
  5. Tap on a button
  6. Confirm Email receipt view is opened
  7. Confirm x closes the view
  8. Try inputting incorrect emails
  9. Confirm validation continues to work
  10. Enter and send email
  11. Confirm the email is received
  12. PTR order list and select order again, confirm order shows a new email address (automatic updating will be done in another PR)

Testing information

  • Tested on iPad 26.0 simulator
  • Tested with 9.6 and 10.0 WooCommerce sites, to verify both send_email and send_order_details endpoints

Screenshots

Simulator.Screen.Recording.-.iPad.Air.13-inch.M3.-.2025-09-09.at.18.53.30.mov

  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

@staskus staskus added this to the 23.3 milestone Sep 9, 2025
@staskus staskus added type: task An internally driven task. feature: POS labels Sep 9, 2025
@dangermattic
Copy link
Collaborator

1 Warning
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Sep 9, 2025

App Icon📲 You can test the changes from this Pull Request in WooCommerce iOS Prototype by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS Prototype
Build Numberpr16110-4ae1b3c
Version23.2
Bundle IDcom.automattic.alpha.woocommerce
Commit4ae1b3c
Installation URL3sasocqcpl7v0
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@staskus staskus changed the title [Woo POS][Historical Orders] Order Details: Email Receipt action (Wireframe UI) [Woo POS][Historical Orders] Order Details: Email Receipt action (Wireframe UI) (1/1) Sep 10, 2025
@jaclync jaclync self-assigned this Sep 11, 2025
Copy link
Contributor

@jaclync jaclync left a comment

Choose a reason for hiding this comment

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

Works as expected 🚀 left some non-blocking comments on the code.

Some observations from testing:

  1. (minor) In both light and dark modes, the selected order row background color could have a bit more contrast from the unselected rows.
dark light
Simulator Screenshot - iPad (A16) - 2025-09-11 at 15 25 23 Simulator Screenshot - iPad (A16) - 2025-09-11 at 15 36 28
  1. My test store's time zone is 20 hours behind my device time zone. The date in the POS order UI was first shown in the device time zone, which is different from what's shown in the email, and in the order details UI in the app. Interestingly, after checking the orders tab in the app, the order date became displayed in the store time zone. Another minor consideration is to show the "payment date" instead of the order creation date.

Note the order date in the screenshots.

order UI before sending email order details order UI after sending email / checking orders tab (not sure if it's relevant)
Simulator Screenshot - iPad (A16) - 2025-09-11 at 15 28 59 Simulator Screenshot - iPad (A16) - 2025-09-11 at 15 30 08 Simulator Screenshot - iPad (A16) - 2025-09-11 at 15 31 23

import struct Yosemite.Order

final class MockPOSReceiptController: POSReceiptControllerProtocol {
var shouldThrowReceiptError: Bool = false
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: maybe this variable could be more generic to allow any type of error instead of PointOfSaleOrderController.PointOfSaleOrderControllerError.noOrder; or indicating that this variable is for throwing this specific error

@@ -0,0 +1,95 @@
import Foundation
import Observation
Copy link
Contributor

Choose a reason for hiding this comment

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

super nit: this import doesn't seem needed? It's interesting periphery doesn't detect unused imports.

func sendReceipt(orderID: Int64, recipientEmail: String) async throws
}

final class POSReceiptController: POSReceiptControllerProtocol {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: how about some other name like POSReceiptSender/POSReceiptSendingService? As this class doesn't seem to hold any states for POS, a minor naming suggestion to avoid "controller" based on the POS documentation on controllers:

#### Controllers
Each `Controller` handles a piece of global shared state, and provides the interface for interacting with it.
Often, the best way to do that is to expose a single enum covering all valid possibilities for that state, rather than exposing model objects. We've not achieved that in all the controllers yet, and some global state hasn't been encapsulated in a controller successfully yet, for example card payments.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for sharing. Yes, I think it's better to rename it to keep controller behavior consistent 👍

@@ -85,7 +88,7 @@ struct PointOfSaleEntryPointView: View {
.environmentObject(posModalManager)
.environmentObject(posSheetManager)
.environmentObject(posCoverManager)
.environment(PointOfSaleOrderListModel(ordersController: ordersController))
.environment(PointOfSaleOrderListModel(ordersController: ordersController, receiptController: receiptController))
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: how about DI'ing PointOfSaleOrderListModel instead of POSReceiptControllerProtocol , so that PointOfSaleOrderListModel is explicitly owned?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated it to be the same way as with aggregateModel. Keeping it in the entryPointView as @State

Comment on lines 463 to 467
method: .post,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true)
Copy link
Contributor

Choose a reason for hiding this comment

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

super nit: indentation

Comment on lines +456 to +458
"billing": [
"email": emailAddress
]
Copy link
Contributor

Choose a reason for hiding this comment

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

just wondering, does this affect other billing fields than the email field like the address? I'm guessing/hoping it doesn't. If it does, we might want to stay with the previous order remote update.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, it doesn't affect other billing fields. I tested. It targets only the email address.

Comment on lines 170 to 171
@Test
func updatePOSOrder_calls_remote_updatePOSOrderEmail_with_correct_parameters() async throws {
Copy link
Contributor

Choose a reason for hiding this comment

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

super nit: could be in the same line? Same for the other test case.


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")
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: it looks like this icon only work for system symbols? Maybe can make it more obvious in the function signature.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed to systemName: to follow the Image(..) initializer

Comment on lines 11 to 16
let mockOrderService = MockPOSOrderService()
let mockReceiptService = MockReceiptService()
let mockAnalyticsProvider = MockAnalyticsProvider()
let mockFeatureFlagService = MockFeatureFlagService()
let mockPluginsService = MockPluginsService()
let sut: POSReceiptController
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: could these be private?

Comment on lines -633 to -720
@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: "[email protected]")

// 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: "[email protected]")
} 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: "[email protected]")
} 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)
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: do you think we can keep the analytics tests, or maybe they are not necessary for some reasons?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I moved them to POSReceiptSenderTests, they still exist 👍

@staskus
Copy link
Contributor Author

staskus commented Sep 11, 2025

Thank you for your thorough review, @jaclync! Great comments as always. I addressed everything except the selection color. I will leave these updates after the designer input 👍

@staskus staskus force-pushed the woomob-1140-woo-poshistorical-orders-order-details-email-receipt-action branch from 31d1f6b to 4ae1b3c Compare September 11, 2025 20:09
@staskus staskus merged commit 7b5f2dc into trunk Sep 12, 2025
14 checks passed
@staskus staskus deleted the woomob-1140-woo-poshistorical-orders-order-details-email-receipt-action branch September 12, 2025 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature: POS type: task An internally driven task.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants