Skip to content

Commit 6f79e8d

Browse files
authored
Merge pull request #6495 from woocommerce/feat/5984-add-payment-method-to-collect-payment-events
IPP analytics: add `payment_method_type` property to `card_present_collect_payment_success` and `card_present_collect_payment_failed` events
2 parents bb11d05 + a8c0841 commit 6f79e8d

File tree

11 files changed

+151
-32
lines changed

11 files changed

+151
-32
lines changed

Hardware/Hardware/CardReader/CardReaderServiceError.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public enum CardReaderServiceError: Error {
1212
/// Error thrown while connecting to a reader
1313
case connection(underlyingError: UnderlyingError = .internalServiceError)
1414

15-
/// Error thrown while disonnecting from a reader
15+
/// Error thrown while disconnecting from a reader
1616
case disconnection(underlyingError: UnderlyingError = .internalServiceError)
1717

1818
/// Error thrown while creating a payment intent
@@ -24,6 +24,10 @@ public enum CardReaderServiceError: Error {
2424
/// Error thrown while capturing a payment
2525
case paymentCapture(underlyingError: UnderlyingError = .internalServiceError)
2626

27+
/// Error thrown when the order payment fails to be captured with a known payment method.
28+
/// The payment method is currently used for analytics.
29+
case paymentCaptureWithPaymentMethod(underlyingError: Error, paymentMethod: PaymentMethod)
30+
2731
/// Error thrown while cancelling a payment
2832
case paymentCancellation(underlyingError: UnderlyingError = .internalServiceError)
2933

@@ -58,6 +62,8 @@ extension CardReaderServiceError: LocalizedError {
5862
.refundCancellation(let underlyingError),
5963
.softwareUpdate(let underlyingError, _):
6064
return underlyingError.errorDescription
65+
case .paymentCaptureWithPaymentMethod(underlyingError: let underlyingError, paymentMethod: _):
66+
return (underlyingError as? UnderlyingError)?.errorDescription ?? underlyingError.localizedDescription
6167
case .bluetoothDenied:
6268
return NSLocalizedString(
6369
"This app needs permission to access Bluetooth to connect to a card reader, please change the privacy settings if you wish to allow this.",

Hardware/Hardware/CardReader/PaymentIntent.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,12 @@ public extension PaymentIntent {
105105
return metadata
106106
}
107107
}
108+
109+
public extension PaymentIntent {
110+
/// Returns the payment method from a PaymentIntent if available, typically after the payment is processed.
111+
/// Before the payment is processed, `nil` is returned.
112+
/// - Returns: an optional payment method that is set after the payment is processed.
113+
func paymentMethod() -> PaymentMethod? {
114+
charges.first?.paymentMethod
115+
}
116+
}

Hardware/Hardware/CardReader/PaymentMethod.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// The type of the PaymentMethod.
2-
public enum PaymentMethod {
2+
public enum PaymentMethod: Equatable {
33
/// A card payment method.
44
case card
55

Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,12 @@ private extension StripeCardReaderService {
461461
Terminal.shared.processPayment(intent) { (intent, error) in
462462
if let error = error {
463463
let underlyingError = UnderlyingError(with: error)
464-
promise(.failure(CardReaderServiceError.paymentCapture(underlyingError: underlyingError)))
464+
if let paymentMethod = error.paymentIntent.map({ PaymentIntent(intent: $0) })?.paymentMethod() {
465+
promise(.failure(CardReaderServiceError.paymentCaptureWithPaymentMethod(underlyingError: underlyingError,
466+
paymentMethod: paymentMethod)))
467+
} else {
468+
promise(.failure(CardReaderServiceError.paymentCapture(underlyingError: underlyingError)))
469+
}
465470
}
466471

467472
if let intent = intent {

Hardware/HardwareTests/PaymentIntentTests.swift

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,43 @@ final class PaymentIntentTests: XCTestCase {
4848
// It is not possible to instantiate a SCPCharge, which is what
4949
// would be needed to instantiate a mock intent.
5050
// For now, we will rely on counting charges as a way
51-
// to chek that at least both SCPPaymentIntent and
51+
// to check that at least both SCPPaymentIntent and
5252
// PaymentIntent reference the same number of charges 🤷
5353
XCTAssertEqual(intent.charges.count, mockIntent.charges.count)
5454
}
55+
56+
func test_paymentMethod_is_nil_when_there_are_no_charges() {
57+
// When
58+
let intent = PaymentIntent(intent: mockIntent)
59+
60+
// Then
61+
XCTAssertNil(intent.paymentMethod())
62+
}
63+
64+
func test_paymentMethod_is_set_by_the_first_charge_when_there_are_two_charges() {
65+
// When
66+
let intent = PaymentIntent(id: "",
67+
status: .processing,
68+
created: .init(),
69+
amount: 1201,
70+
currency: "cad",
71+
metadata: nil,
72+
charges: [.init(id: "",
73+
amount: 201,
74+
currency: "cad",
75+
status: .failed,
76+
description: nil,
77+
metadata: nil,
78+
paymentMethod: .card),
79+
.init(id: "",
80+
amount: 1000,
81+
currency: "cad",
82+
status: .failed,
83+
description: nil,
84+
metadata: nil,
85+
paymentMethod: .unknown)])
86+
87+
// Then
88+
XCTAssertEqual(intent.paymentMethod(), .card)
89+
}
5590
}

WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,15 @@ import Yosemite
3939
/// ~~~
4040
///
4141
public struct WooAnalyticsEvent {
42+
init(statName: WooAnalyticsStat, properties: [String: WooAnalyticsEventPropertyType], error: Error? = nil) {
43+
self.statName = statName
44+
self.properties = properties
45+
self.error = error
46+
}
47+
4248
let statName: WooAnalyticsStat
4349
let properties: [String: WooAnalyticsEventPropertyType]
50+
let error: Error?
4451
}
4552

4653
// MARK: - In-app Feedback and Survey
@@ -569,6 +576,7 @@ extension WooAnalyticsEvent {
569576
static let countryCode = "country"
570577
static let gatewayID = "plugin_slug"
571578
static let errorDescription = "error_description"
579+
static let paymentMethodType = "payment_method_type"
572580
static let softwareUpdateType = "software_update_type"
573581
}
574582

@@ -779,13 +787,39 @@ extension WooAnalyticsEvent {
779787
/// - countryCode: the country code of the store.
780788
///
781789
static func collectPaymentFailed(forGatewayID: String?, error: Error, countryCode: String) -> WooAnalyticsEvent {
782-
WooAnalyticsEvent(statName: .collectPaymentFailed,
783-
properties: [
784-
Keys.countryCode: countryCode,
785-
Keys.gatewayID: gatewayID(forGatewayID: forGatewayID),
786-
Keys.errorDescription: error.localizedDescription
787-
]
788-
)
790+
let paymentMethod: PaymentMethod? = {
791+
guard case let CardReaderServiceError.paymentCaptureWithPaymentMethod(_, paymentMethod) = error else {
792+
return nil
793+
}
794+
return paymentMethod
795+
}()
796+
let errorDescription: String? = {
797+
guard case let CardReaderServiceError.paymentCaptureWithPaymentMethod(underlyingError, paymentMethod) = error else {
798+
return error.localizedDescription
799+
}
800+
switch paymentMethod {
801+
case let .cardPresent(details):
802+
return [
803+
"underlyingError": underlyingError,
804+
"cardBrand": details.brand
805+
].description
806+
case let .interacPresent(details):
807+
return [
808+
"underlyingError": underlyingError,
809+
"cardBrand": details.brand
810+
].description
811+
default:
812+
return underlyingError.localizedDescription
813+
}
814+
}()
815+
let properties: [String: WooAnalyticsEventPropertyType] = [
816+
Keys.countryCode: countryCode,
817+
Keys.gatewayID: gatewayID(forGatewayID: forGatewayID),
818+
Keys.paymentMethodType: paymentMethod?.analyticsValue,
819+
Keys.errorDescription: errorDescription
820+
].compactMapValues { $0 }
821+
return WooAnalyticsEvent(statName: .collectPaymentFailed,
822+
properties: properties)
789823
}
790824

791825
/// Tracked when the payment collection is cancelled
@@ -808,12 +842,14 @@ extension WooAnalyticsEvent {
808842
/// - Parameters:
809843
/// - forGatewayID: the plugin (e.g. "woocommerce-payments" or "woocommerce-gateway-stripe") to be included in the event properties in Tracks.
810844
/// - countryCode: the country code of the store.
845+
/// - paymentMethod: the payment method of the captured payment.
811846
///
812-
static func collectPaymentSuccess(forGatewayID: String?, countryCode: String) -> WooAnalyticsEvent {
847+
static func collectPaymentSuccess(forGatewayID: String?, countryCode: String, paymentMethod: PaymentMethod) -> WooAnalyticsEvent {
813848
WooAnalyticsEvent(statName: .collectPaymentSuccess,
814849
properties: [
815850
Keys.countryCode: countryCode,
816-
Keys.gatewayID: gatewayID(forGatewayID: forGatewayID)
851+
Keys.gatewayID: gatewayID(forGatewayID: forGatewayID),
852+
Keys.paymentMethodType: paymentMethod.analyticsValue
817853
]
818854
)
819855
}
@@ -842,3 +878,16 @@ extension WooAnalyticsEvent {
842878
}
843879
}
844880
}
881+
882+
private extension PaymentMethod {
883+
var analyticsValue: String {
884+
switch self {
885+
case .card, .cardPresent:
886+
return "card"
887+
case .interacPresent:
888+
return "card_interac"
889+
case .unknown:
890+
return "unknown"
891+
}
892+
}
893+
}

WooCommerce/Classes/ServiceLocator/Analytics.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,6 @@ extension Analytics {
6565
/// - Parameter event: The event to track along with its properties.
6666
///
6767
func track(event: WooAnalyticsEvent) {
68-
track(event.statName, withProperties: event.properties)
68+
track(event.statName, properties: event.properties, error: event.error)
6969
}
7070
}

WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import Yosemite
22
import PassKit
33

4+
/// Contains data associated with a payment that has been collected, processed, and captured.
5+
struct CardPresentCapturedPaymentData {
6+
/// Currently used for analytics.
7+
let paymentMethod: PaymentMethod
8+
9+
/// Used for receipt generation for display in the app.
10+
let receiptParameters: CardPresentReceiptParameters
11+
}
12+
413
/// Orchestrates the sequence of actions required to capture a payment:
514
/// 1. Check if there is a card reader connected
615
/// 2. Launch the reader discovering and pairing UI if there is no reader connected
@@ -21,7 +30,7 @@ final class PaymentCaptureOrchestrator {
2130
onWaitingForInput: @escaping () -> Void,
2231
onProcessingMessage: @escaping () -> Void,
2332
onDisplayMessage: @escaping (String) -> Void,
24-
onCompletion: @escaping (Result<CardPresentReceiptParameters, Error>) -> Void) {
33+
onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> Void) {
2534
/// Bail out if the order amount is below the minimum allowed:
2635
/// https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
2736
guard isTotalAmountValid(order: order) else {
@@ -119,7 +128,7 @@ final class PaymentCaptureOrchestrator {
119128
}
120129

121130
private extension PaymentCaptureOrchestrator {
122-
/// Supress wallet presentation. This requires a special entitlement from Apple:
131+
/// Suppress wallet presentation. This requires a special entitlement from Apple:
123132
/// `com.apple.developer.passkit.pass-presentation-suppression`
124133
/// See Woo-*.entitlements in WooCommerce/Resources
125134
///
@@ -169,7 +178,7 @@ private extension PaymentCaptureOrchestrator {
169178
private extension PaymentCaptureOrchestrator {
170179
func completePaymentIntentCapture(order: Order,
171180
captureResult: Result<PaymentIntent, Error>,
172-
onCompletion: @escaping (Result<CardPresentReceiptParameters, Error>) -> Void) {
181+
onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> Void) {
173182
switch captureResult {
174183
case .failure(let error):
175184
onCompletion(.failure(error))
@@ -184,15 +193,16 @@ private extension PaymentCaptureOrchestrator {
184193
func submitPaymentIntent(siteID: Int64,
185194
order: Order,
186195
paymentIntent: PaymentIntent,
187-
onCompletion: @escaping (Result<CardPresentReceiptParameters, Error>) -> Void) {
196+
onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> Void) {
188197
let action = CardPresentPaymentAction.captureOrderPayment(siteID: siteID,
189198
orderID: order.orderID,
190199
paymentIntentID: paymentIntent.id) { [weak self] result in
191200
guard let self = self else {
192201
return
193202
}
194203

195-
guard let receiptParameters = paymentIntent.receiptParameters() else {
204+
guard let paymentMethod = paymentIntent.paymentMethod(),
205+
let receiptParameters = paymentIntent.receiptParameters() else {
196206
let error = CardReaderServiceError.paymentCapture()
197207

198208
DDLogError("⛔️ Payment completed without required metadata: \(error)")
@@ -205,9 +215,10 @@ private extension PaymentCaptureOrchestrator {
205215
case .success:
206216
self.celebrate() // plays a sound, haptic
207217
self.saveReceipt(for: order, params: receiptParameters)
208-
onCompletion(.success(receiptParameters))
218+
onCompletion(.success(.init(paymentMethod: paymentMethod,
219+
receiptParameters: receiptParameters)))
209220
case .failure(let error):
210-
onCompletion(.failure(error))
221+
onCompletion(.failure(CardReaderServiceError.paymentCaptureWithPaymentMethod(underlyingError: error, paymentMethod: paymentMethod)))
211222
return
212223
}
213224
}

WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,10 @@ final class CollectOrderPaymentUseCase: NSObject, CollectOrderPaymentProtocol {
116116
onCollect(result.map { _ in () }) // Transforms Result<CardPresentReceiptParameters, Error> to Result<Void, Error>
117117

118118
// Handle payment receipt
119-
guard let receiptParameters = try? result.get() else {
119+
guard let paymentData = try? result.get() else {
120120
return
121121
}
122-
self?.presentReceiptAlert(receiptParameters: receiptParameters, backButtonTitle: backButtonTitle, onCompleted: onCompleted)
122+
self?.presentReceiptAlert(receiptParameters: paymentData.receiptParameters, backButtonTitle: backButtonTitle, onCompleted: onCompleted)
123123
})
124124
}
125125
}
@@ -169,7 +169,7 @@ private extension CollectOrderPaymentUseCase {
169169

170170
/// Attempts to collect payment for an order.
171171
///
172-
func attemptPayment(onCompletion: @escaping (Result<CardPresentReceiptParameters, Error>) -> ()) {
172+
func attemptPayment(onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> ()) {
173173
// Track tapped event
174174
analytics.track(event: WooAnalyticsEvent.InPersonPayments.collectPaymentTapped(forGatewayID: paymentGatewayAccount.gatewayID,
175175
countryCode: configurationLoader.configuration.countryCode))
@@ -198,8 +198,8 @@ private extension CollectOrderPaymentUseCase {
198198

199199
}, onCompletion: { [weak self] result in
200200
switch result {
201-
case .success(let receiptParameters):
202-
self?.handleSuccessfulPayment(receipt: receiptParameters, onCompletion: onCompletion)
201+
case .success(let capturedPaymentData):
202+
self?.handleSuccessfulPayment(capturedPaymentData: capturedPaymentData, onCompletion: onCompletion)
203203
case .failure(let error):
204204
self?.handlePaymentFailureAndRetryPayment(error, onCompletion: onCompletion)
205205
}
@@ -209,18 +209,21 @@ private extension CollectOrderPaymentUseCase {
209209

210210
/// Tracks the successful payments
211211
///
212-
func handleSuccessfulPayment(receipt: CardPresentReceiptParameters, onCompletion: @escaping (Result<CardPresentReceiptParameters, Error>) -> ()) {
212+
func handleSuccessfulPayment(capturedPaymentData: CardPresentCapturedPaymentData,
213+
onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> ()) {
213214
// Record success
214-
analytics.track(event: WooAnalyticsEvent.InPersonPayments.collectPaymentSuccess(forGatewayID: paymentGatewayAccount.gatewayID,
215-
countryCode: configurationLoader.configuration.countryCode))
215+
analytics.track(event: WooAnalyticsEvent.InPersonPayments
216+
.collectPaymentSuccess(forGatewayID: paymentGatewayAccount.gatewayID,
217+
countryCode: configurationLoader.configuration.countryCode,
218+
paymentMethod: capturedPaymentData.paymentMethod))
216219

217220
// Success Callback
218-
onCompletion(.success(receipt))
221+
onCompletion(.success(capturedPaymentData))
219222
}
220223

221224
/// Log the failure reason, cancel the current payment and retry it if possible.
222225
///
223-
func handlePaymentFailureAndRetryPayment(_ error: Error, onCompletion: @escaping (Result<CardPresentReceiptParameters, Error>) -> ()) {
226+
func handlePaymentFailureAndRetryPayment(_ error: Error, onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> ()) {
224227
// Record error
225228
analytics.track(event: WooAnalyticsEvent.InPersonPayments.collectPaymentFailed(forGatewayID: paymentGatewayAccount.gatewayID,
226229
error: error,

Yosemite/Yosemite/Model/Model.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ public typealias CardReaderServiceDiscoveryStatus = Hardware.CardReaderServiceDi
135135
public typealias CardReaderServiceError = Hardware.CardReaderServiceError
136136
public typealias CardReaderType = Hardware.CardReaderType
137137
public typealias CardReaderConfigError = Hardware.CardReaderConfigError
138+
public typealias PaymentMethod = Hardware.PaymentMethod
138139
public typealias PaymentParameters = Hardware.PaymentIntentParameters
139140
public typealias RefundParameters = Hardware.RefundParameters
140141
public typealias PaymentIntent = Hardware.PaymentIntent

0 commit comments

Comments
 (0)