Skip to content

Commit

Permalink
[Woo POS] MVP Analytics: Card payment success event properties (#15148)
Browse files Browse the repository at this point in the history
  • Loading branch information
iamgabrielma authored Feb 18, 2025
2 parents bfafb75 + af7ce8f commit 06b9022
Show file tree
Hide file tree
Showing 12 changed files with 326 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -1,17 +1,48 @@
import protocol WooFoundation.Analytics
import Yosemite

final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTracking {
var connectedReaderModel: String?

private var customerInteractionStarted: Double = 0
private var orderCreated: Double = 0
private var cardReaderReady: Double = 0
private var cardReaderTapped: Double = 0
private var checkoutTapCount: Int = 0
private var hasTrackedProcessingPayment = false

private let analytics: Analytics

init(analytics: Analytics = ServiceLocator.analytics) {
self.analytics = analytics
}

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

func trackSuccessfulPayment(capturedPaymentData: CardPresentCapturedPaymentData) {
let elapsedTime = calculateElapsedTimeInMilliseconds(start: customerInteractionStarted, end: Date().timeIntervalSince1970)
ServiceLocator.analytics.track(event:
.PointOfSale.cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStated: elapsedTime))
// Property: milliseconds_since_customer_interaction_started
let elapsedTimeSinceCustomerInteraction = calculateElapsedTimeInMilliseconds(since: customerInteractionStarted)

// Property: milliseconds_since_order_creation_success
let elapsedTimeSinceOrderCreation = calculateElapsedTimeInMilliseconds(since: orderCreated)

// Property: milliseconds_since_reader_ready_to_collect_payment
let elapsedTimeSinceCardReaderReady = calculateElapsedTimeInMilliseconds(since: cardReaderReady)

// Property: milliseconds_since_card_tapped
let elapsedTimeSinceCardTapped = calculateElapsedTimeInMilliseconds(since: cardReaderTapped)

analytics.track(event: .PointOfSale.cardPresentCollectPaymentSuccess(
millisecondsSinceCustomerIteractionStarted: elapsedTimeSinceCustomerInteraction,
millisecondsSinceOrderCreationSuccess: elapsedTimeSinceOrderCreation,
millisecondsSinceReaderReadyToCollect: elapsedTimeSinceCardReaderReady,
millisecondsSinceCardTapped: elapsedTimeSinceCardTapped,
checkoutTapCount: checkoutTapCount
))

resetCheckoutTapCountTracker()
resetProcessingPaymentTracking()
}

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

func trackCustomerInteractionStarted() {
// Any action that is considered as user starting an iteraction resets any ongoing counter
resetAllCountersOnInteractionStarted()
customerInteractionStarted = Date().timeIntervalSince1970
}

private func calculateElapsedTimeInMilliseconds(start: Double, end: Double) -> Double {
floor((end - start) * 1000)
func trackOrderCreationSuccess() {
orderCreated = trackCurrentTime()
}

func trackCardReaderReady() {
cardReaderReady = trackCurrentTime()
}

// The Stripe SDK returns multiple `.processing` events, but we want to capture the first one in the stream only.
// This flag is reset as soon as the payment has been successful
func trackCardReaderTapped() {
if !hasTrackedProcessingPayment {
hasTrackedProcessingPayment = true
cardReaderTapped = trackCurrentTime()
}
}

func trackCheckoutTapped() {
checkoutTapCount += 1
}

func resetCheckoutTapCountTracker() {
checkoutTapCount = 0
}
}

// Helpers
private extension POSCollectOrderPaymentAnalytics {
func trackCurrentTime() -> Double {
Date().timeIntervalSince1970
}

func calculateElapsedTimeInMilliseconds(since start: Double) -> Double {
let end = Date().timeIntervalSince1970
return floor((end - start) * 1000)
}

private func resetProcessingPaymentTracking() {
hasTrackedProcessingPayment = false
}

private func resetAllCountersOnInteractionStarted() {
orderCreated = 0
cardReaderReady = 0
cardReaderTapped = 0
resetCheckoutTapCountTracker()
resetProcessingPaymentTracking()
}
}

// Protocol conformance. These events are not needed for IPP, only for POS.
// https://github.com/woocommerce/woocommerce-ios/issues/15149
extension CollectOrderPaymentAnalytics {
func trackCustomerInteractionStarted() { }
func trackOrderCreationSuccess() { }
func trackCardReaderReady() { }
func trackCardReaderTapped() { }
func trackCheckoutTapped() { }
func resetCheckoutTapCountTracker() { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ extension WooAnalyticsEvent {
static let itemType = "product_type"
static let itemsInCart = "items_in_cart"
static let millisecondsSinceCustomerInteractionStarted = "milliseconds_since_customer_interaction_started"
static let millisecondsSinceOrderCreationSuccess = "milliseconds_since_order_creation_success"
static let millisecondsSinceReaderReadyToCollect = "milliseconds_since_reader_ready_to_collect_payment"
static let millisecondsSinceCardTapped = "milliseconds_since_card_tapped"
static let checkoutTapCount = "checkout_tap_count"
}

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

static func cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStated: Double) -> WooAnalyticsEvent {
static func cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStarted: Double,
millisecondsSinceOrderCreationSuccess: Double,
millisecondsSinceReaderReadyToCollect: Double,
millisecondsSinceCardTapped: Double,
checkoutTapCount: Int) -> WooAnalyticsEvent {
WooAnalyticsEvent(statName: .collectPaymentSuccess, properties: [
Key.millisecondsSinceCustomerInteractionStarted: "\(millisecondsSinceCustomerIteractionStated)"]
)
Key.millisecondsSinceCustomerInteractionStarted: "\(millisecondsSinceCustomerIteractionStarted)",
Key.millisecondsSinceOrderCreationSuccess: "\(millisecondsSinceOrderCreationSuccess)",
Key.millisecondsSinceReaderReadyToCollect: "\(millisecondsSinceReaderReadyToCollect)",
Key.millisecondsSinceCardTapped: "\(millisecondsSinceCardTapped)",
Key.checkoutTapCount: "\(checkoutTapCount)"
])
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,21 @@ import class WooFoundation.CurrencySettings
import enum WooFoundation.CurrencyCode
import protocol WooFoundation.Analytics

enum SyncOrderState {
case newOrder
case orderUpdated
case orderNotChanged
}

enum SyncOrderStateError: Error {
case syncFailure
}

protocol PointOfSaleOrderControllerProtocol {
var orderState: PointOfSaleInternalOrderState { get }

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

@MainActor
@MainActor @discardableResult
func syncOrder(for cartItems: [CartItem],
retryHandler: @escaping () async -> Void) async {
retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
let posCartItems = cartItems.map {
POSCartItem(item: $0.item, quantity: Decimal($0.quantity))
}

guard !orderState.isSyncing,
!posCartItems.matches(order: order) else {
return
guard !orderState.isSyncing, !posCartItems.matches(order: order) else {
return .success(.orderNotChanged)
}

orderState = .syncing
Expand All @@ -74,6 +84,9 @@ protocol PointOfSaleOrderControllerProtocol {
orderState = .loaded(totals(for: syncedOrder), syncedOrder)
if isNewOrder {
analytics.track(.orderCreationSuccess)
return .success(.newOrder)
} else {
return .success(.orderUpdated)
}
} catch {
if isNewOrder {
Expand All @@ -83,6 +96,7 @@ protocol PointOfSaleOrderControllerProtocol {
errorDescription: error.localizedDescription))
}
setOrderStateToError(error, retryHandler: retryHandler)
return .failure(SyncOrderStateError.syncFailure)
}
}

Expand Down
16 changes: 15 additions & 1 deletion WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ extension PointOfSaleAggregateModel {

private func cashPaymentSuccess() {
paymentState = .cash(.paymentSuccess)
// TODO: Move to trackSuccessfulCashPayment() on #15151
collectOrderPaymentAnalyticsTracker.resetCheckoutTapCountTracker()
}

@MainActor
Expand Down Expand Up @@ -365,6 +367,14 @@ private extension PointOfSaleAggregateModel {
let newPaymentState = PointOfSalePaymentState(from: paymentEvent,
using: presentationStyleDeterminerDependencies)

if case .card(.acceptingCard) = newPaymentState {
collectOrderPaymentAnalyticsTracker.trackCardReaderReady()
}

if case .card(.processingPayment) = newPaymentState {
collectOrderPaymentAnalyticsTracker.trackCardReaderTapped()
}

return newPaymentState
}
.sink(receiveValue: { [weak self] paymentState in
Expand Down Expand Up @@ -438,10 +448,14 @@ private extension PointOfSaleAggregateModel {
extension PointOfSaleAggregateModel {
@MainActor
func checkOut() async {
collectOrderPaymentAnalyticsTracker.trackCheckoutTapped()
orderStage = .finalizing
await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
let syncOrderResult = await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
await self?.checkOut()
})
if case .success(.newOrder) = syncOrderResult {
collectOrderPaymentAnalyticsTracker.trackOrderCreationSuccess()
}
await startPaymentWhenCardReaderConnected()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol {
OrderFactory.newOrder(currency: .USD)
)

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

func sendReceipt(recipientEmail: String) async throws { }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ protocol CollectOrderPaymentAnalyticsTracking {
func trackReceiptPrintFailed(error: Error)

func trackCustomerInteractionStarted()
func trackOrderCreationSuccess()
func trackCardReaderReady()
func trackCardReaderTapped()
func trackCheckoutTapped()
func resetCheckoutTapCountTracker()
}

final class CollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTracking {
Expand Down
12 changes: 12 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,7 @@
68709D3D2A2ED94900A7FA6C /* UpgradesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */; };
68709D402A2EE2DC00A7FA6C /* UpgradesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */; };
6879B8DB287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */; };
687C006F2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 687C006E2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift */; };
6881CCC42A5EE6BF00AEDE36 /* WooPlanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6881CCC32A5EE6BF00AEDE36 /* WooPlanCardView.swift */; };
6885E2CC2C32B14B004C8D70 /* TotalsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6885E2CB2C32B14B004C8D70 /* TotalsViewHelper.swift */; };
6888A2C82A668D650026F5C0 /* FullFeatureListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888A2C72A668D650026F5C0 /* FullFeatureListView.swift */; };
Expand Down Expand Up @@ -4739,6 +4740,7 @@
68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesView.swift; sourceTree = "<group>"; };
68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesViewModel.swift; sourceTree = "<group>"; };
6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsViewModelTests.swift; sourceTree = "<group>"; };
687C006E2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCollectOrderPaymentAnalyticsTests.swift; sourceTree = "<group>"; };
6881CCC32A5EE6BF00AEDE36 /* WooPlanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPlanCardView.swift; sourceTree = "<group>"; };
6885E2CB2C32B14B004C8D70 /* TotalsViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalsViewHelper.swift; sourceTree = "<group>"; };
6888A2C72A668D650026F5C0 /* FullFeatureListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullFeatureListView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -9848,6 +9850,14 @@
path = InAppPurchases;
sourceTree = "<group>";
};
687C006C2D63469F00F832FC /* Analytics */ = {
isa = PBXGroup;
children = (
687C006E2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift */,
);
path = Analytics;
sourceTree = "<group>";
};
68DF5A8B2CB38EC5000154C9 /* Coupons */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -12991,6 +13001,7 @@
DABF35242C11B40C006AF826 /* POS */ = {
isa = PBXGroup;
children = (
687C006C2D63469F00F832FC /* Analytics */,
200BA15C2CF0A9D90006DC5B /* Controllers */,
20ADE9442C6B361500C91265 /* Card Present Payments */,
DAD988C72C4A9D49009DE9E3 /* Models */,
Expand Down Expand Up @@ -17249,6 +17260,7 @@
DA24152B2D116EAE0008F69A /* WooShippingAddPackageViewModelTests.swift in Sources */,
DE19BB1D26C6911900AB70D9 /* ShippingLabelCustomsFormListViewModelTests.swift in Sources */,
D449C52C26E02F2F00D75B02 /* WhatsNewFactoryTests.swift in Sources */,
687C006F2D6346E300F832FC /* POSCollectOrderPaymentAnalyticsTests.swift in Sources */,
5761298B24589B84007BB2D9 /* NumberFormatter+LocalizedOrNinetyNinePlusTests.swift in Sources */,
EE19058A2B590FF800617C53 /* BlazePaymentMethodsViewModelTests.swift in Sources */,
2667BFD7252E5DBF008099D4 /* RefundItemViewModelTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,25 @@ final class MockCollectOrderPaymentAnalyticsTracker: CollectOrderPaymentAnalytic
func trackCustomerInteractionStarted() {
// no-op
}

func trackOrderCreationSuccess() {
// no-op
}

func trackCardReaderReady() {
// no-op
}

func trackCardReaderTapped() {
// no-op
}

var didCallTrackCheckoutTapped = false
func trackCheckoutTapped() {
didCallTrackCheckoutTapped = true
}

func resetCheckoutTapCountTracker() {
// no-op
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@testable import WooCommerce
import protocol WooFoundation.Analytics
import Testing

struct POSCollectOrderPaymentAnalyticsTests {
private let analytics: Analytics
private let analyticsProvider: MockAnalyticsProvider

init() {
analyticsProvider = MockAnalyticsProvider()
analytics = WooAnalytics(analyticsProvider: analyticsProvider)
}

@Test func POSCollectOrderPaymentAnalyticsTests_when_successful_payment_then_tracks_event_and_properties() {
// Given
let sut = POSCollectOrderPaymentAnalytics(analytics: analytics)
let capturedPaymentData = CardPresentCapturedPaymentData(paymentMethod: .cardPresent(details: .fake()), receiptParameters: nil)
let expectedEvent = "card_present_collect_payment_success"
let expectedProperties = [
"milliseconds_since_order_creation_success",
"milliseconds_since_reader_ready_to_collect_payment",
"milliseconds_since_card_tapped",
"milliseconds_since_customer_interaction_started",
"checkout_tap_count"
]

// When
sut.trackSuccessfulPayment(capturedPaymentData: capturedPaymentData)

// Then
#expect(analyticsProvider.receivedEvents.first(where: { $0 == expectedEvent }) != nil)
#expect(expectedProperties.allSatisfy { key in
analyticsProvider.receivedProperties.contains(where: { $0.keys.contains(key) })
})
}
}
Loading

0 comments on commit 06b9022

Please sign in to comment.