Skip to content

Commit 06b9022

Browse files
authored
[Woo POS] MVP Analytics: Card payment success event properties (#15148)
2 parents bfafb75 + af7ce8f commit 06b9022

12 files changed

+326
-19
lines changed
Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,48 @@
1+
import protocol WooFoundation.Analytics
12
import Yosemite
23

34
final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTracking {
45
var connectedReaderModel: String?
56

67
private var customerInteractionStarted: Double = 0
8+
private var orderCreated: Double = 0
9+
private var cardReaderReady: Double = 0
10+
private var cardReaderTapped: Double = 0
11+
private var checkoutTapCount: Int = 0
12+
private var hasTrackedProcessingPayment = false
13+
14+
private let analytics: Analytics
15+
16+
init(analytics: Analytics = ServiceLocator.analytics) {
17+
self.analytics = analytics
18+
}
719

820
func preflightResultReceived(_ result: CardReaderPreflightResult?) { }
921
func trackProcessingCompletion(intent: Yosemite.PaymentIntent) { }
1022

1123
func trackSuccessfulPayment(capturedPaymentData: CardPresentCapturedPaymentData) {
12-
let elapsedTime = calculateElapsedTimeInMilliseconds(start: customerInteractionStarted, end: Date().timeIntervalSince1970)
13-
ServiceLocator.analytics.track(event:
14-
.PointOfSale.cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStated: elapsedTime))
24+
// Property: milliseconds_since_customer_interaction_started
25+
let elapsedTimeSinceCustomerInteraction = calculateElapsedTimeInMilliseconds(since: customerInteractionStarted)
26+
27+
// Property: milliseconds_since_order_creation_success
28+
let elapsedTimeSinceOrderCreation = calculateElapsedTimeInMilliseconds(since: orderCreated)
29+
30+
// Property: milliseconds_since_reader_ready_to_collect_payment
31+
let elapsedTimeSinceCardReaderReady = calculateElapsedTimeInMilliseconds(since: cardReaderReady)
32+
33+
// Property: milliseconds_since_card_tapped
34+
let elapsedTimeSinceCardTapped = calculateElapsedTimeInMilliseconds(since: cardReaderTapped)
35+
36+
analytics.track(event: .PointOfSale.cardPresentCollectPaymentSuccess(
37+
millisecondsSinceCustomerIteractionStarted: elapsedTimeSinceCustomerInteraction,
38+
millisecondsSinceOrderCreationSuccess: elapsedTimeSinceOrderCreation,
39+
millisecondsSinceReaderReadyToCollect: elapsedTimeSinceCardReaderReady,
40+
millisecondsSinceCardTapped: elapsedTimeSinceCardTapped,
41+
checkoutTapCount: checkoutTapCount
42+
))
43+
44+
resetCheckoutTapCountTracker()
45+
resetProcessingPaymentTracking()
1546
}
1647

1748
func trackPaymentFailure(with error: any Error) { }
@@ -23,15 +54,68 @@ final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTrackin
2354
func trackReceiptPrintFailed(error: any Error) { }
2455

2556
func trackCustomerInteractionStarted() {
57+
// Any action that is considered as user starting an iteraction resets any ongoing counter
58+
resetAllCountersOnInteractionStarted()
2659
customerInteractionStarted = Date().timeIntervalSince1970
2760
}
2861

29-
private func calculateElapsedTimeInMilliseconds(start: Double, end: Double) -> Double {
30-
floor((end - start) * 1000)
62+
func trackOrderCreationSuccess() {
63+
orderCreated = trackCurrentTime()
64+
}
65+
66+
func trackCardReaderReady() {
67+
cardReaderReady = trackCurrentTime()
68+
}
69+
70+
// The Stripe SDK returns multiple `.processing` events, but we want to capture the first one in the stream only.
71+
// This flag is reset as soon as the payment has been successful
72+
func trackCardReaderTapped() {
73+
if !hasTrackedProcessingPayment {
74+
hasTrackedProcessingPayment = true
75+
cardReaderTapped = trackCurrentTime()
76+
}
77+
}
78+
79+
func trackCheckoutTapped() {
80+
checkoutTapCount += 1
81+
}
82+
83+
func resetCheckoutTapCountTracker() {
84+
checkoutTapCount = 0
85+
}
86+
}
87+
88+
// Helpers
89+
private extension POSCollectOrderPaymentAnalytics {
90+
func trackCurrentTime() -> Double {
91+
Date().timeIntervalSince1970
92+
}
93+
94+
func calculateElapsedTimeInMilliseconds(since start: Double) -> Double {
95+
let end = Date().timeIntervalSince1970
96+
return floor((end - start) * 1000)
97+
}
98+
99+
private func resetProcessingPaymentTracking() {
100+
hasTrackedProcessingPayment = false
101+
}
102+
103+
private func resetAllCountersOnInteractionStarted() {
104+
orderCreated = 0
105+
cardReaderReady = 0
106+
cardReaderTapped = 0
107+
resetCheckoutTapCountTracker()
108+
resetProcessingPaymentTracking()
31109
}
32110
}
33111

34112
// Protocol conformance. These events are not needed for IPP, only for POS.
113+
// https://github.com/woocommerce/woocommerce-ios/issues/15149
35114
extension CollectOrderPaymentAnalytics {
36115
func trackCustomerInteractionStarted() { }
116+
func trackOrderCreationSuccess() { }
117+
func trackCardReaderReady() { }
118+
func trackCardReaderTapped() { }
119+
func trackCheckoutTapped() { }
120+
func resetCheckoutTapCountTracker() { }
37121
}

WooCommerce/Classes/POS/Analytics/WooAnalyticsEvent+PointOfSale.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ extension WooAnalyticsEvent {
1313
static let itemType = "product_type"
1414
static let itemsInCart = "items_in_cart"
1515
static let millisecondsSinceCustomerInteractionStarted = "milliseconds_since_customer_interaction_started"
16+
static let millisecondsSinceOrderCreationSuccess = "milliseconds_since_order_creation_success"
17+
static let millisecondsSinceReaderReadyToCollect = "milliseconds_since_reader_ready_to_collect_payment"
18+
static let millisecondsSinceCardTapped = "milliseconds_since_card_tapped"
19+
static let checkoutTapCount = "checkout_tap_count"
1620
}
1721

1822
static func paymentsOnboardingShown() -> WooAnalyticsEvent {
@@ -33,10 +37,18 @@ extension WooAnalyticsEvent {
3337
properties: [Key.itemsInCart: itemsInCart])
3438
}
3539

36-
static func cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStated: Double) -> WooAnalyticsEvent {
40+
static func cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStarted: Double,
41+
millisecondsSinceOrderCreationSuccess: Double,
42+
millisecondsSinceReaderReadyToCollect: Double,
43+
millisecondsSinceCardTapped: Double,
44+
checkoutTapCount: Int) -> WooAnalyticsEvent {
3745
WooAnalyticsEvent(statName: .collectPaymentSuccess, properties: [
38-
Key.millisecondsSinceCustomerInteractionStarted: "\(millisecondsSinceCustomerIteractionStated)"]
39-
)
46+
Key.millisecondsSinceCustomerInteractionStarted: "\(millisecondsSinceCustomerIteractionStarted)",
47+
Key.millisecondsSinceOrderCreationSuccess: "\(millisecondsSinceOrderCreationSuccess)",
48+
Key.millisecondsSinceReaderReadyToCollect: "\(millisecondsSinceReaderReadyToCollect)",
49+
Key.millisecondsSinceCardTapped: "\(millisecondsSinceCardTapped)",
50+
Key.checkoutTapCount: "\(checkoutTapCount)"
51+
])
4052
}
4153
}
4254
}

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: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ extension PointOfSaleAggregateModel {
250250

251251
private func cashPaymentSuccess() {
252252
paymentState = .cash(.paymentSuccess)
253+
// TODO: Move to trackSuccessfulCashPayment() on #15151
254+
collectOrderPaymentAnalyticsTracker.resetCheckoutTapCountTracker()
253255
}
254256

255257
@MainActor
@@ -365,6 +367,14 @@ private extension PointOfSaleAggregateModel {
365367
let newPaymentState = PointOfSalePaymentState(from: paymentEvent,
366368
using: presentationStyleDeterminerDependencies)
367369

370+
if case .card(.acceptingCard) = newPaymentState {
371+
collectOrderPaymentAnalyticsTracker.trackCardReaderReady()
372+
}
373+
374+
if case .card(.processingPayment) = newPaymentState {
375+
collectOrderPaymentAnalyticsTracker.trackCardReaderTapped()
376+
}
377+
368378
return newPaymentState
369379
}
370380
.sink(receiveValue: { [weak self] paymentState in
@@ -438,10 +448,14 @@ private extension PointOfSaleAggregateModel {
438448
extension PointOfSaleAggregateModel {
439449
@MainActor
440450
func checkOut() async {
451+
collectOrderPaymentAnalyticsTracker.trackCheckoutTapped()
441452
orderStage = .finalizing
442-
await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
453+
let syncOrderResult = await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
443454
await self?.checkOut()
444455
})
456+
if case .success(.newOrder) = syncOrderResult {
457+
collectOrderPaymentAnalyticsTracker.trackOrderCreationSuccess()
458+
}
445459
await startPaymentWhenCardReaderConnected()
446460
}
447461
}

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/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentAnalytics.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ protocol CollectOrderPaymentAnalyticsTracking {
2626
func trackReceiptPrintFailed(error: Error)
2727

2828
func trackCustomerInteractionStarted()
29+
func trackOrderCreationSuccess()
30+
func trackCardReaderReady()
31+
func trackCardReaderTapped()
32+
func trackCheckoutTapped()
33+
func resetCheckoutTapCountTracker()
2934
}
3035

3136
final class CollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTracking {

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,6 +1588,7 @@
15881588
68709D3D2A2ED94900A7FA6C /* UpgradesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */; };
15891589
68709D402A2EE2DC00A7FA6C /* UpgradesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */; };
15901590
6879B8DB287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */; };
1591+
687C006F2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 687C006E2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift */; };
15911592
6881CCC42A5EE6BF00AEDE36 /* WooPlanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6881CCC32A5EE6BF00AEDE36 /* WooPlanCardView.swift */; };
15921593
6885E2CC2C32B14B004C8D70 /* TotalsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6885E2CB2C32B14B004C8D70 /* TotalsViewHelper.swift */; };
15931594
6888A2C82A668D650026F5C0 /* FullFeatureListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888A2C72A668D650026F5C0 /* FullFeatureListView.swift */; };
@@ -4739,6 +4740,7 @@
47394740
68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesView.swift; sourceTree = "<group>"; };
47404741
68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesViewModel.swift; sourceTree = "<group>"; };
47414742
6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsViewModelTests.swift; sourceTree = "<group>"; };
4743+
687C006E2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCollectOrderPaymentAnalyticsTests.swift; sourceTree = "<group>"; };
47424744
6881CCC32A5EE6BF00AEDE36 /* WooPlanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPlanCardView.swift; sourceTree = "<group>"; };
47434745
6885E2CB2C32B14B004C8D70 /* TotalsViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalsViewHelper.swift; sourceTree = "<group>"; };
47444746
6888A2C72A668D650026F5C0 /* FullFeatureListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullFeatureListView.swift; sourceTree = "<group>"; };
@@ -9848,6 +9850,14 @@
98489850
path = InAppPurchases;
98499851
sourceTree = "<group>";
98509852
};
9853+
687C006C2D63469F00F832FC /* Analytics */ = {
9854+
isa = PBXGroup;
9855+
children = (
9856+
687C006E2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift */,
9857+
);
9858+
path = Analytics;
9859+
sourceTree = "<group>";
9860+
};
98519861
68DF5A8B2CB38EC5000154C9 /* Coupons */ = {
98529862
isa = PBXGroup;
98539863
children = (
@@ -12991,6 +13001,7 @@
1299113001
DABF35242C11B40C006AF826 /* POS */ = {
1299213002
isa = PBXGroup;
1299313003
children = (
13004+
687C006C2D63469F00F832FC /* Analytics */,
1299413005
200BA15C2CF0A9D90006DC5B /* Controllers */,
1299513006
20ADE9442C6B361500C91265 /* Card Present Payments */,
1299613007
DAD988C72C4A9D49009DE9E3 /* Models */,
@@ -17249,6 +17260,7 @@
1724917260
DA24152B2D116EAE0008F69A /* WooShippingAddPackageViewModelTests.swift in Sources */,
1725017261
DE19BB1D26C6911900AB70D9 /* ShippingLabelCustomsFormListViewModelTests.swift in Sources */,
1725117262
D449C52C26E02F2F00D75B02 /* WhatsNewFactoryTests.swift in Sources */,
17263+
687C006F2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift in Sources */,
1725217264
5761298B24589B84007BB2D9 /* NumberFormatter+LocalizedOrNinetyNinePlusTests.swift in Sources */,
1725317265
EE19058A2B590FF800617C53 /* BlazePaymentMethodsViewModelTests.swift in Sources */,
1725417266
2667BFD7252E5DBF008099D4 /* RefundItemViewModelTests.swift in Sources */,

WooCommerce/WooCommerceTests/Mocks/MockCollectOrderPaymentAnalyticsTracker.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,25 @@ final class MockCollectOrderPaymentAnalyticsTracker: CollectOrderPaymentAnalytic
5757
func trackCustomerInteractionStarted() {
5858
// no-op
5959
}
60+
61+
func trackOrderCreationSuccess() {
62+
// no-op
63+
}
64+
65+
func trackCardReaderReady() {
66+
// no-op
67+
}
68+
69+
func trackCardReaderTapped() {
70+
// no-op
71+
}
72+
73+
var didCallTrackCheckoutTapped = false
74+
func trackCheckoutTapped() {
75+
didCallTrackCheckoutTapped = true
76+
}
77+
78+
func resetCheckoutTapCountTracker() {
79+
// no-op
80+
}
6081
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
@testable import WooCommerce
2+
import protocol WooFoundation.Analytics
3+
import Testing
4+
5+
struct POSCollectOrderPaymentAnalyticsTests {
6+
private let analytics: Analytics
7+
private let analyticsProvider: MockAnalyticsProvider
8+
9+
init() {
10+
analyticsProvider = MockAnalyticsProvider()
11+
analytics = WooAnalytics(analyticsProvider: analyticsProvider)
12+
}
13+
14+
@Test func POSCollectOrderPaymentAnalyticsTests_when_successful_payment_then_tracks_event_and_properties() {
15+
// Given
16+
let sut = POSCollectOrderPaymentAnalytics(analytics: analytics)
17+
let capturedPaymentData = CardPresentCapturedPaymentData(paymentMethod: .cardPresent(details: .fake()), receiptParameters: nil)
18+
let expectedEvent = "card_present_collect_payment_success"
19+
let expectedProperties = [
20+
"milliseconds_since_order_creation_success",
21+
"milliseconds_since_reader_ready_to_collect_payment",
22+
"milliseconds_since_card_tapped",
23+
"milliseconds_since_customer_interaction_started",
24+
"checkout_tap_count"
25+
]
26+
27+
// When
28+
sut.trackSuccessfulPayment(capturedPaymentData: capturedPaymentData)
29+
30+
// Then
31+
#expect(analyticsProvider.receivedEvents.first(where: { $0 == expectedEvent }) != nil)
32+
#expect(expectedProperties.allSatisfy { key in
33+
analyticsProvider.receivedProperties.contains(where: { $0.keys.contains(key) })
34+
})
35+
}
36+
}

0 commit comments

Comments
 (0)