Skip to content

Commit 050f2fa

Browse files
committed
Merge branch 'task/collect-payment-success-event-properties' into task/pos-remaining-mvp-events
# Conflicts: # WooCommerce/Classes/POS/Analytics/POSCollectOrderPaymentAnalytics.swift # WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
2 parents 28cac57 + af7ce8f commit 050f2fa

File tree

6 files changed

+146
-14
lines changed

6 files changed

+146
-14
lines changed

WooCommerce/Classes/POS/Analytics/POSCollectOrderPaymentAnalytics.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTrackin
99
private var cardReaderReady: Double = 0
1010
private var cardReaderTapped: Double = 0
1111
private var checkoutTapCount: Int = 0
12+
private var hasTrackedProcessingPayment = false
1213

1314
private let analytics: Analytics
1415

@@ -39,6 +40,9 @@ final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTrackin
3940
millisecondsSinceCardTapped: elapsedTimeSinceCardTapped,
4041
checkoutTapCount: checkoutTapCount
4142
))
43+
44+
resetCheckoutTapCountTracker()
45+
resetProcessingPaymentTracking()
4246
}
4347

4448
func trackSuccessfulCashPayment() {
@@ -58,6 +62,8 @@ final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTrackin
5862
func trackReceiptPrintFailed(error: any Error) { }
5963

6064
func trackCustomerInteractionStarted() {
65+
// Any action that is considered as user starting an iteraction resets any ongoing counter
66+
resetAllCountersOnInteractionStarted()
6167
analytics.track(.pointOfSaleInteractionWithCustomerStarted)
6268
customerInteractionStarted = Date().timeIntervalSince1970
6369
}
@@ -73,8 +79,13 @@ final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTrackin
7379
trackElapsedTimeFromOrderCreationToCardReady()
7480
}
7581

82+
// The Stripe SDK returns multiple `.processing` events, but we want to capture the first one in the stream only.
83+
// This flag is reset as soon as the payment has been successful
7684
func trackCardReaderTapped() {
77-
cardReaderTapped = trackCurrentTime()
85+
if !hasTrackedProcessingPayment {
86+
hasTrackedProcessingPayment = true
87+
cardReaderTapped = trackCurrentTime()
88+
}
7889
}
7990

8091
func trackCheckoutTapped() {
@@ -101,6 +112,18 @@ private extension POSCollectOrderPaymentAnalytics {
101112
let end = Date().timeIntervalSince1970
102113
return floor((end - start) * 1000)
103114
}
115+
116+
private func resetProcessingPaymentTracking() {
117+
hasTrackedProcessingPayment = false
118+
}
119+
120+
private func resetAllCountersOnInteractionStarted() {
121+
orderCreated = 0
122+
cardReaderReady = 0
123+
cardReaderTapped = 0
124+
resetCheckoutTapCountTracker()
125+
resetProcessingPaymentTracking()
126+
}
104127
}
105128

106129
// Protocol conformance. These events are not needed for IPP, only for POS.

WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,21 @@ import class WooFoundation.CurrencySettings
1313
import enum WooFoundation.CurrencyCode
1414
import protocol WooFoundation.Analytics
1515

16+
enum SyncOrderState {
17+
case newOrder
18+
case orderUpdated
19+
case orderNotChanged
20+
}
21+
22+
enum SyncOrderStateError: Error {
23+
case syncFailure
24+
}
25+
1626
protocol PointOfSaleOrderControllerProtocol {
1727
var orderState: PointOfSaleInternalOrderState { get }
1828

19-
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async
29+
@discardableResult
30+
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error>
2031
func sendReceipt(recipientEmail: String) async throws
2132
func clearOrder()
2233
func collectCashPayment() async throws
@@ -51,16 +62,15 @@ protocol PointOfSaleOrderControllerProtocol {
5162
private(set) var orderState: PointOfSaleInternalOrderState = .idle
5263
private var order: Order? = nil
5364

54-
@MainActor
65+
@MainActor @discardableResult
5566
func syncOrder(for cartItems: [CartItem],
56-
retryHandler: @escaping () async -> Void) async {
67+
retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
5768
let posCartItems = cartItems.map {
5869
POSCartItem(item: $0.item, quantity: Decimal($0.quantity))
5970
}
6071

61-
guard !orderState.isSyncing,
62-
!posCartItems.matches(order: order) else {
63-
return
72+
guard !orderState.isSyncing, !posCartItems.matches(order: order) else {
73+
return .success(.orderNotChanged)
6474
}
6575

6676
orderState = .syncing
@@ -74,6 +84,9 @@ protocol PointOfSaleOrderControllerProtocol {
7484
orderState = .loaded(totals(for: syncedOrder), syncedOrder)
7585
if isNewOrder {
7686
analytics.track(.orderCreationSuccess)
87+
return .success(.newOrder)
88+
} else {
89+
return .success(.orderUpdated)
7790
}
7891
} catch {
7992
if isNewOrder {
@@ -83,6 +96,7 @@ protocol PointOfSaleOrderControllerProtocol {
8396
errorDescription: error.localizedDescription))
8497
}
8598
setOrderStateToError(error, retryHandler: retryHandler)
99+
return .failure(SyncOrderStateError.syncFailure)
86100
}
87101
}
88102

WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,6 @@ extension PointOfSaleAggregateModel {
227227
}
228228
do {
229229
try await collectPayment(for: order)
230-
collectOrderPaymentAnalyticsTracker.resetCheckoutTapCountTracker()
231230
} catch {
232231
DDLogError("Error taking payment: \(error)")
233232
}
@@ -253,6 +252,7 @@ extension PointOfSaleAggregateModel {
253252
private func cashPaymentSuccess() {
254253
paymentState = .cash(.paymentSuccess)
255254
collectOrderPaymentAnalyticsTracker.trackSuccessfulCashPayment()
255+
// TODO: Move to trackSuccessfulCashPayment() on #15151
256256
collectOrderPaymentAnalyticsTracker.resetCheckoutTapCountTracker()
257257
}
258258

@@ -452,10 +452,12 @@ extension PointOfSaleAggregateModel {
452452
func checkOut() async {
453453
collectOrderPaymentAnalyticsTracker.trackCheckoutTapped()
454454
orderStage = .finalizing
455-
await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
455+
let syncOrderResult = await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
456456
await self?.checkOut()
457457
})
458-
collectOrderPaymentAnalyticsTracker.trackOrderCreationSuccess()
458+
if case .success(.newOrder) = syncOrderResult {
459+
collectOrderPaymentAnalyticsTracker.trackOrderCreationSuccess()
460+
}
459461
await startPaymentWhenCardReaderConnected()
460462
}
461463
}

WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol {
1212
OrderFactory.newOrder(currency: .USD)
1313
)
1414

15-
func syncOrder(for cartProducts: [CartItem],
16-
retryHandler: @escaping () async -> Void) async { }
15+
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
16+
return .success(.newOrder)
17+
}
1718

1819
func sendReceipt(recipientEmail: String) async throws { }
1920

WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,94 @@ struct PointOfSaleOrderControllerTests {
276276
#expect(mockPaymentCelebration.celebrationWasCalled == true)
277277
}
278278

279+
@available(iOS 17.0, *)
280+
@Test func syncOrder_when_successful_returns_newOrder_result() async throws {
281+
// Given
282+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
283+
receiptService: mockReceiptService)
284+
let fakeOrderItem = OrderItem.fake().copy(quantity: 1)
285+
let fakeOrder = Order.fake()
286+
let fakeCartItem = makeItem(orderItemsToMatch: [fakeOrderItem])
287+
mockOrderService.orderToReturn = fakeOrder
288+
289+
// When
290+
let result = await sut.syncOrder(for: [fakeCartItem], retryHandler: { })
291+
292+
// Then
293+
if case .success(let state) = result {
294+
#expect(state == .newOrder)
295+
} else {
296+
#expect(Bool(false), "Expected success result with new order")
297+
}
298+
}
299+
300+
@available(iOS 17.0, *)
301+
@Test func syncOrder_when_updating_existing_order_returns_orderUpdated_result() async throws {
302+
// Given
303+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
304+
receiptService: mockReceiptService)
305+
let fakeOrderItem = OrderItem.fake().copy(quantity: 1)
306+
let fakeOrder = Order.fake()
307+
mockOrderService.orderToReturn = fakeOrder
308+
309+
// When
310+
// 1. Initial order
311+
_ = await sut.syncOrder(for: [makeItem()], retryHandler: {})
312+
313+
// 2. Sync existing order
314+
let result = await sut.syncOrder(for: [makeItem(), makeItem()], retryHandler: {})
315+
316+
// Then
317+
if case .success(let state) = result {
318+
#expect(state == .orderUpdated)
319+
} else {
320+
#expect(Bool(false), "Expected success result with order updated")
321+
}
322+
}
323+
324+
@available(iOS 17.0, *)
325+
@Test func syncOrder_when_cart_matching_order_then_returns_orderNotChanged_result() async throws {
326+
// Given
327+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
328+
receiptService: mockReceiptService)
329+
let orderItem = OrderItem.fake().copy(quantity: 1)
330+
let fakeOrder = Order.fake().copy(items: [orderItem])
331+
let cartItem = makeItem(orderItemsToMatch: [orderItem])
332+
mockOrderService.orderToReturn = fakeOrder
333+
334+
// When
335+
// 1. Initial order
336+
_ = await sut.syncOrder(for: [cartItem], retryHandler: {})
337+
338+
// 2. Syncing existing order with same cart should not update order
339+
let result = await sut.syncOrder(for: [cartItem], retryHandler: {})
340+
341+
// Then
342+
if case .success(let state) = result {
343+
#expect(state == .orderNotChanged)
344+
} else {
345+
#expect(Bool(false), "Expected success result with no changes to existing order")
346+
}
347+
}
348+
349+
@available(iOS 17.0, *)
350+
@Test func syncOrder_when_orderService_fails_then_returns_syncOrderState_failure() async throws {
351+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
352+
receiptService: mockReceiptService)
353+
let cartItem = makeItem(quantity: 1)
354+
355+
// When
356+
mockOrderService.orderToReturn = nil
357+
let result = await sut.syncOrder(for: [cartItem], retryHandler: {})
358+
359+
// Then
360+
if case .failure(let error) = result {
361+
#expect(error as? SyncOrderStateError == .syncFailure)
362+
} else {
363+
#expect(Bool(false), "Expected sync failure but got \(result)")
364+
}
365+
}
366+
279367
struct AnalyticsTests {
280368
private let analytics: WooAnalytics
281369
private let analyticsProvider = MockAnalyticsProvider()

WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,22 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
1818
var syncOrderWasCalled: Bool = false
1919
var spyCartProducts: [CartItem]?
2020
var spyRetryHandler: (() async -> Void)?
21+
var syncOrderResultToReturn: Result<SyncOrderState, Error> = .success(.newOrder)
22+
23+
@discardableResult
2124
func syncOrder(for cartProducts: [CartItem],
22-
retryHandler: @escaping () async -> Void) async {
25+
retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
2326
syncOrderWasCalled = true
2427
spyCartProducts = cartProducts
2528
spyRetryHandler = retryHandler
2629

2730
guard let orderStateToReturn else {
2831
orderState = .syncing
29-
return
32+
return syncOrderResultToReturn
3033
}
3134

3235
orderState = orderStateToReturn
36+
return syncOrderResultToReturn
3337
}
3438

3539
var clearOrderWasCalled: Bool = false

0 commit comments

Comments
 (0)