From fcb411ae96a20524ca0ee0b943fa1473fc7e0cef Mon Sep 17 00:00:00 2001 From: erenbesel Date: Mon, 11 Nov 2024 16:58:10 +0100 Subject: [PATCH 01/13] add new level field to initial analytics --- .../AnalyticsProvider/AnalyticsProvider.swift | 8 ++++---- Adyen/Analytics/Models/AdyenAnalytics.swift | 17 +++++++++++++++++ Adyen/Analytics/Models/AnalyticsConstants.swift | 3 +++ Adyen/Analytics/Models/AnalyticsData.swift | 10 +++++++--- .../Requests/InitialAnalyticsRequest.swift | 3 +++ Adyen/Core/AdyenContext/AdyenContext.swift | 2 +- .../Analytics/AnalyticsEventTests.swift | 7 +++---- .../Analytics/AnalyticsProviderTests.swift | 17 +++++++++++------ 8 files changed, 49 insertions(+), 18 deletions(-) diff --git a/Adyen/Analytics/AnalyticsProvider/AnalyticsProvider.swift b/Adyen/Analytics/AnalyticsProvider/AnalyticsProvider.swift index a9d97157e6..b192f311ad 100644 --- a/Adyen/Analytics/AnalyticsProvider/AnalyticsProvider.swift +++ b/Adyen/Analytics/AnalyticsProvider/AnalyticsProvider.swift @@ -20,16 +20,16 @@ internal final class AnalyticsProvider: AnyAnalyticsProvider { internal var eventAnalyticsProvider: AnyEventAnalyticsProvider? private let uniqueAssetAPIClient: UniqueAssetAPIClient - private let context: AnalyticsContext + private let configuration: AnalyticsConfiguration // MARK: - Initializers internal init( apiClient: APIClientProtocol, - context: AnalyticsContext, + configuration: AnalyticsConfiguration, eventAnalyticsProvider: AnyEventAnalyticsProvider? ) { - self.context = context + self.configuration = configuration self.eventAnalyticsProvider = eventAnalyticsProvider self.uniqueAssetAPIClient = UniqueAssetAPIClient(apiClient: apiClient) } @@ -40,7 +40,7 @@ internal final class AnalyticsProvider: AnyAnalyticsProvider { let analyticsData = AnalyticsData( flavor: flavor, additionalFields: additionalFields, - context: context + configuration: configuration ) let initialAnalyticsRequest = InitialAnalyticsRequest(data: analyticsData) diff --git a/Adyen/Analytics/Models/AdyenAnalytics.swift b/Adyen/Analytics/Models/AdyenAnalytics.swift index 0074a6256d..006a958953 100644 --- a/Adyen/Analytics/Models/AdyenAnalytics.swift +++ b/Adyen/Analytics/Models/AdyenAnalytics.swift @@ -74,3 +74,20 @@ public struct AdditionalAnalyticsFields { self.sessionId = sessionId } } + +/// Describes the levels that determine which analytics calls are made. +internal enum AnalyticsLevel: String, Encodable { + + /// Indicates all analytics are enabled. + case all + + /// Indicates only the initial call is enabled. + case initial +} + +extension AnalyticsConfiguration { + + internal var analyticsLevel: AnalyticsLevel { + isEnabled ? .all : .initial + } +} diff --git a/Adyen/Analytics/Models/AnalyticsConstants.swift b/Adyen/Analytics/Models/AnalyticsConstants.swift index 14e282e14c..c9e28748cf 100644 --- a/Adyen/Analytics/Models/AnalyticsConstants.swift +++ b/Adyen/Analytics/Models/AnalyticsConstants.swift @@ -12,6 +12,9 @@ public enum AnalyticsConstants { /// A constant to pass into the payment data object in the case where fetching the checkout attempt Id fails. public static let fetchCheckoutAttemptIdFailed = "fetch-checkoutAttemptId-failed" + /// A constant to pass into the payment data object in the case where the checkout attempt id is never fetched (e.g., instant components). + public static let checkoutAttemptIdNotFetched = "checkoutAttemptId-not-fetched" + public enum ValidationErrorCodes { public static let cardNumberEmpty = 900 diff --git a/Adyen/Analytics/Models/AnalyticsData.swift b/Adyen/Analytics/Models/AnalyticsData.swift index 9507187453..0ce16f8858 100644 --- a/Adyen/Analytics/Models/AnalyticsData.swift +++ b/Adyen/Analytics/Models/AnalyticsData.swift @@ -86,20 +86,24 @@ internal struct AnalyticsData: Encodable { internal var paymentMethods: [String] = [] internal let component: String + + internal let level: AnalyticsLevel // MARK: - Initializers internal init( flavor: AnalyticsFlavor, additionalFields: AdditionalAnalyticsFields?, - context: AnalyticsContext + configuration: AnalyticsConfiguration ) { self.flavor = flavor.value self.amount = additionalFields?.amount self.sessionId = additionalFields?.sessionId - self.version = context.version - self.platform = context.platform.rawValue + self.version = configuration.context.version + self.platform = configuration.context.platform.rawValue + + self.level = configuration.analyticsLevel switch flavor { case let .dropIn(type, paymentMethods): diff --git a/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift b/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift index 5500cc3961..aef914e4f7 100644 --- a/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift +++ b/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift @@ -47,6 +47,7 @@ internal struct InitialAnalyticsRequest: APIRequest { private let containerWidth: Int? private let paymentMethods: [String] private let component: String + private let level: String internal let amount: Amount? internal let sessionId: String? @@ -67,6 +68,7 @@ internal struct InitialAnalyticsRequest: APIRequest { self.containerWidth = data.containerWidth self.paymentMethods = data.paymentMethods self.component = data.component + self.level = data.level.rawValue self.amount = data.amount self.sessionId = data.sessionId } @@ -86,6 +88,7 @@ internal struct InitialAnalyticsRequest: APIRequest { case containerWidth case paymentMethods case component + case level case amount case sessionId } diff --git a/Adyen/Core/AdyenContext/AdyenContext.swift b/Adyen/Core/AdyenContext/AdyenContext.swift index 2df3afc601..723dabb5e3 100644 --- a/Adyen/Core/AdyenContext/AdyenContext.swift +++ b/Adyen/Core/AdyenContext/AdyenContext.swift @@ -81,7 +81,7 @@ public final class AdyenContext: PaymentAware { return AnalyticsProvider( apiClient: APIClient(apiContext: analyticsApiContext), - context: analyticsConfiguration.context, + configuration: analyticsConfiguration, eventAnalyticsProvider: eventAnalyticsProvider ) } diff --git a/Tests/UnitTests/Analytics/AnalyticsEventTests.swift b/Tests/UnitTests/Analytics/AnalyticsEventTests.swift index ff801dfca1..c5feb228c6 100644 --- a/Tests/UnitTests/Analytics/AnalyticsEventTests.swift +++ b/Tests/UnitTests/Analytics/AnalyticsEventTests.swift @@ -21,7 +21,7 @@ class AnalyticsEventTests: XCTestCase { apiClient.mockedResults = [checkoutAttemptIdResult] sut = AnalyticsProvider( apiClient: apiClient, - context: .init(), + configuration: .init(), eventAnalyticsProvider: nil ) } @@ -40,7 +40,7 @@ class AnalyticsEventTests: XCTestCase { // Given sut = AnalyticsProvider( apiClient: apiClient, - context: .init(), + configuration: .init(), eventAnalyticsProvider: nil ) @@ -61,10 +61,9 @@ class AnalyticsEventTests: XCTestCase { func testSendInitialEventGivenEnabledAndFlavorIsDropInShouldSendInitialRequest() throws { // Given - let analyticsConfiguration = AnalyticsConfiguration() sut = AnalyticsProvider( apiClient: apiClient, - context: .init(), + configuration: .init(), eventAnalyticsProvider: nil ) diff --git a/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift b/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift index eb03c17145..8f2ddbe113 100644 --- a/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift +++ b/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift @@ -12,10 +12,9 @@ class AnalyticsProviderTests: XCTestCase { func testAnalyticsProviderIsInitializedWithCorrectDefaultConfigurationValues() throws { // Given - let analyticsConfiguration = AnalyticsConfiguration() let sut = AnalyticsProvider( apiClient: APIClientMock(), - context: AnalyticsContext(), + configuration: AnalyticsConfiguration(), eventAnalyticsProvider: nil ) @@ -129,7 +128,7 @@ class AnalyticsProviderTests: XCTestCase { ) let sut = AnalyticsProvider( apiClient: APIClientMock(), - context: AnalyticsContext(), + configuration: AnalyticsConfiguration(), eventAnalyticsProvider: eventAnalyticsProvider ) @@ -199,9 +198,11 @@ class AnalyticsProviderTests: XCTestCase { } } + var configuration = AnalyticsConfiguration() + configuration.context = AnalyticsContext(version: "version", platform: .reactNative) let analyticsProvider = AnalyticsProvider( apiClient: apiClient, - context: .init(version: "version", platform: .reactNative), + configuration: configuration, eventAnalyticsProvider: nil ) @@ -214,10 +215,13 @@ class AnalyticsProviderTests: XCTestCase { func testInitialRequestEncoding() throws { + var configuration = AnalyticsConfiguration() + configuration.context = AnalyticsContext(version: "version", platform: .flutter) + let analyticsData = AnalyticsData( flavor: .components(type: .achDirectDebit), additionalFields: AdditionalAnalyticsFields(amount: .init(value: 1, currencyCode: "EUR"), sessionId: "test_session_id"), - context: AnalyticsContext(version: "version", platform: .flutter) + configuration: configuration ) let request = InitialAnalyticsRequest(data: analyticsData) @@ -237,6 +241,7 @@ class AnalyticsProviderTests: XCTestCase { "referrer": analyticsData.referrer, "deviceBrand": analyticsData.deviceBrand, "deviceModel": analyticsData.deviceModel, + "level": analyticsData.level.rawValue, "amount": [ "currency": "EUR", "value": 1 @@ -260,7 +265,7 @@ class AnalyticsProviderTests: XCTestCase { private func createSUT(apiClient: APIClientMock) -> AnalyticsProvider { let sut = AnalyticsProvider( apiClient: apiClient, - context: AnalyticsContext(), + configuration: AnalyticsConfiguration(), eventAnalyticsProvider: nil ) From 31dc37b8932fac46a8dbee8a7be7b365290f484e Mon Sep 17 00:00:00 2001 From: erenbesel Date: Mon, 18 Nov 2024 11:30:57 +0100 Subject: [PATCH 02/13] add level value to tests --- Adyen/Analytics/Requests/InitialAnalyticsRequest.swift | 2 +- Tests/UnitTests/Analytics/AnalyticsProviderTests.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift b/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift index aef914e4f7..1b264f5e1b 100644 --- a/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift +++ b/Adyen/Analytics/Requests/InitialAnalyticsRequest.swift @@ -47,7 +47,7 @@ internal struct InitialAnalyticsRequest: APIRequest { private let containerWidth: Int? private let paymentMethods: [String] private let component: String - private let level: String + internal let level: String internal let amount: Amount? internal let sessionId: String? diff --git a/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift b/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift index 8f2ddbe113..0e681e692e 100644 --- a/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift +++ b/Tests/UnitTests/Analytics/AnalyticsProviderTests.swift @@ -102,6 +102,7 @@ class AnalyticsProviderTests: XCTestCase { XCTAssertNil(initialAnalyticsdRequest.amount) XCTAssertEqual(initialAnalyticsdRequest.version, adyenSdkVersion) XCTAssertEqual(initialAnalyticsdRequest.platform, "iOS") + XCTAssertEqual(initialAnalyticsdRequest.level, "all") analyticsExpectation.fulfill() } } From a1e3083bd8151469515cc83c68387d26ae35c151 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Mon, 18 Nov 2024 11:37:23 +0100 Subject: [PATCH 03/13] remove constant which was not needed yet --- Adyen/Analytics/Models/AnalyticsConstants.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Adyen/Analytics/Models/AnalyticsConstants.swift b/Adyen/Analytics/Models/AnalyticsConstants.swift index c9e28748cf..14e282e14c 100644 --- a/Adyen/Analytics/Models/AnalyticsConstants.swift +++ b/Adyen/Analytics/Models/AnalyticsConstants.swift @@ -12,9 +12,6 @@ public enum AnalyticsConstants { /// A constant to pass into the payment data object in the case where fetching the checkout attempt Id fails. public static let fetchCheckoutAttemptIdFailed = "fetch-checkoutAttemptId-failed" - /// A constant to pass into the payment data object in the case where the checkout attempt id is never fetched (e.g., instant components). - public static let checkoutAttemptIdNotFetched = "checkoutAttemptId-not-fetched" - public enum ValidationErrorCodes { public static let cardNumberEmpty = 900 From d47f1faf4f19ccf7d562cd41b40f159944023c50 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Mon, 18 Nov 2024 15:04:53 +0100 Subject: [PATCH 04/13] disable buggy swiftformat rule --- .swiftformat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.swiftformat b/.swiftformat index 5a1970efc8..852bd27148 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,4 +1,4 @@ ---disable blankLinesAtStartOfScope,blankLinesAtEndOfScope,unusedArguments,redundantSelf,wrapMultilineStatementBraces,extensionAccessControl,redundantClosure,redundantInternal,preferForLoop,redundantRawValues +--disable blankLinesAtStartOfScope,blankLinesAtEndOfScope,unusedArguments,redundantSelf,wrapMultilineStatementBraces,extensionAccessControl,redundantClosure,redundantInternal,preferForLoop,redundantRawValues,docCommentsBeforeModifiers --swiftversion "5.7" --commas inline --ranges nospace From dbad3559bd54c2020f3fbf42d6198d2298632181 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Wed, 4 Dec 2024 11:53:43 +0100 Subject: [PATCH 05/13] send redirect error events --- .../EventAnalyticsProvider.swift | 10 ++++-- .../Analytics/Models/AnalyticsConstants.swift | 31 +++++++++++++++++++ .../Models/AnalyticsEventError.swift | 6 ++-- .../Redirect/RedirectComponent.swift | 12 +++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Adyen/Analytics/AnalyticsProvider/EventAnalyticsProvider.swift b/Adyen/Analytics/AnalyticsProvider/EventAnalyticsProvider.swift index 73c5af6bdc..00b691e952 100644 --- a/Adyen/Analytics/AnalyticsProvider/EventAnalyticsProvider.swift +++ b/Adyen/Analytics/AnalyticsProvider/EventAnalyticsProvider.swift @@ -16,7 +16,14 @@ internal final class EventAnalyticsProvider: AnyEventAnalyticsProvider { static let errorLimit = 5 } - internal var checkoutAttemptId: String? + internal var checkoutAttemptId: String? { + didSet { + if checkoutAttemptId != nil { + startNextTimer() + } + } + } + internal let apiClient: APIClientProtocol internal let eventDataSource: AnyAnalyticsEventDataSource @@ -34,7 +41,6 @@ internal final class EventAnalyticsProvider: AnyEventAnalyticsProvider { self.eventDataSource = eventDataSource self.context = context self.batchInterval = batchInterval - startNextTimer() } deinit { diff --git a/Adyen/Analytics/Models/AnalyticsConstants.swift b/Adyen/Analytics/Models/AnalyticsConstants.swift index 14e282e14c..9cdb993ceb 100644 --- a/Adyen/Analytics/Models/AnalyticsConstants.swift +++ b/Adyen/Analytics/Models/AnalyticsConstants.swift @@ -12,6 +12,37 @@ public enum AnalyticsConstants { /// A constant to pass into the payment data object in the case where fetching the checkout attempt Id fails. public static let fetchCheckoutAttemptIdFailed = "fetch-checkoutAttemptId-failed" + /// Struct to hold error codes as type-safe static variables. + public struct ErrorCode { + + public static let redirectFailed = ErrorCode(600) + public static let redirectParseFailed = ErrorCode(601) + public static let encryptionError = ErrorCode(610) + public static let thirdPartyError = ErrorCode(611) + public static let apiErrorPayments = ErrorCode(620) + public static let apiErrorDetails = ErrorCode(621) + public static let apiErrorThreeDS2 = ErrorCode(622) + public static let apiErrorOrder = ErrorCode(624) + public static let apiErrorPublicKeyFetch = ErrorCode(625) + public static let apiErrorNativeRedirect = ErrorCode(626) + public static let threeDS2PaymentDataMissing = ErrorCode(700) + public static let threeDS2TokenMissing = ErrorCode(701) + public static let threeDS2DecodingFailed = ErrorCode(704) + public static let threeDS2FingerprintCreationFailed = ErrorCode(705) + public static let threeDS2TransactionCreationFailed = ErrorCode(706) + public static let threeDS2TransactionMissing = ErrorCode(707) + public static let threeDS2FingerprintHandlingFailed = ErrorCode(708) + public static let threeDS2ChallengeHandlingFailed = ErrorCode(709) + + public init(_ rawValue: Int) { + self.rawValue = rawValue + } + + private var rawValue: Int + + public var stringValue: String { String(rawValue) } + } + public enum ValidationErrorCodes { public static let cardNumberEmpty = 900 diff --git a/Adyen/Analytics/Models/AnalyticsEventError.swift b/Adyen/Analytics/Models/AnalyticsEventError.swift index 8a7c2e4cc8..15ce19d232 100644 --- a/Adyen/Analytics/Models/AnalyticsEventError.swift +++ b/Adyen/Analytics/Models/AnalyticsEventError.swift @@ -16,7 +16,7 @@ public struct AnalyticsEventError: AnalyticsEvent { public var component: String - public var type: ErrorType + public var errorType: ErrorType public var code: String? @@ -30,10 +30,12 @@ public struct AnalyticsEventError: AnalyticsEvent { case sdk = "SdkError" case thirdParty = "ThirdParty" case generic = "Generic" + case redirect = "Redirect" + case threeDS2 = "ThreeDS2" } public init(component: String, type: ErrorType) { self.component = component - self.type = type + self.errorType = type } } diff --git a/AdyenActions/Components/Redirect/RedirectComponent.swift b/AdyenActions/Components/Redirect/RedirectComponent.swift index 8d471039a5..59d58cb234 100644 --- a/AdyenActions/Components/Redirect/RedirectComponent.swift +++ b/AdyenActions/Components/Redirect/RedirectComponent.swift @@ -157,6 +157,7 @@ public final class RedirectComponent: ActionComponent { self.registerRedirectBounceBackListener(action) self.delegate?.didOpenExternalApplication(component: self) } else { + self.sendErrorEvent(for: .redirect, code: .redirectFailed) self.delegate?.didFail(with: RedirectComponent.Error.appNotFound, from: self) } } @@ -181,6 +182,7 @@ public final class RedirectComponent: ActionComponent { private func handleNativeMobileRedirect(withReturnURL returnURL: URL, redirectStateData: String, _ action: RedirectAction) throws { guard let queryString = returnURL.query else { + sendErrorEvent(for: .redirect, code: .redirectParseFailed) throw Error.invalidRedirectParameters } let request = NativeRedirectResultRequest( @@ -191,6 +193,7 @@ public final class RedirectComponent: ActionComponent { guard let self else { return } switch result { case let .failure(error): + self.sendErrorEvent(for: .api, code: .apiErrorNativeRedirect) self.delegate?.didFail(with: error, from: self) case let .success(response): self.notifyDelegateDidProvide(redirectDetails: response, action) @@ -203,6 +206,15 @@ public final class RedirectComponent: ActionComponent { delegate?.didProvide(actionData, from: self) } + private func sendErrorEvent(for type: AnalyticsEventError.ErrorType, code: AnalyticsConstants.ErrorCode) { + var errorEvent = AnalyticsEventError( + component: configuration.componentName, + type: type + ) + errorEvent.code = code.stringValue + context.analyticsProvider?.add(error: errorEvent) + } + } extension RedirectComponent: BrowserComponentDelegate { From a815adc7a54be7b5cd1db9a3bad02383a86143e3 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Wed, 4 Dec 2024 11:54:35 +0100 Subject: [PATCH 06/13] send encryption error events --- .../Components/Card/CardComponentExtensions.swift | 10 ++++++++++ .../GiftCardComponent/GiftCardComponent.swift | 10 ++++++++++ .../Stored Card/StoredCardAlertManager.swift | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/AdyenCard/Components/Card/CardComponentExtensions.swift b/AdyenCard/Components/Card/CardComponentExtensions.swift index e0d3a4a726..32a55edcb8 100644 --- a/AdyenCard/Components/Card/CardComponentExtensions.swift +++ b/AdyenCard/Components/Card/CardComponentExtensions.swift @@ -58,9 +58,19 @@ extension CardComponent { submit(data: data) } catch { + sendEncryptionErrorEvent() delegate?.didFail(with: error, from: self) } } + + private func sendEncryptionErrorEvent() { + var errorEvent = AnalyticsEventError( + component: paymentMethod.type.rawValue, + type: .internal + ) + errorEvent.code = AnalyticsConstants.ErrorCode.encryptionError.stringValue + context.analyticsProvider?.add(error: errorEvent) + } } @_spi(AdyenInternal) diff --git a/AdyenCard/Components/GiftCardComponent/GiftCardComponent.swift b/AdyenCard/Components/GiftCardComponent/GiftCardComponent.swift index 4bea6a2545..ca416ce129 100644 --- a/AdyenCard/Components/GiftCardComponent/GiftCardComponent.swift +++ b/AdyenCard/Components/GiftCardComponent/GiftCardComponent.swift @@ -462,9 +462,19 @@ extension GiftCardComponent { storePaymentMethod: false )) } catch { + sendEncryptionErrorEvent() return .failure(error) } } + + private func sendEncryptionErrorEvent() { + var errorEvent = AnalyticsEventError( + component: paymentMethod.type.rawValue, + type: .internal + ) + errorEvent.code = AnalyticsConstants.ErrorCode.encryptionError.stringValue + context.analyticsProvider?.add(error: errorEvent) + } } @_spi(AdyenInternal) diff --git a/AdyenCard/Components/Stored Card/StoredCardAlertManager.swift b/AdyenCard/Components/Stored Card/StoredCardAlertManager.swift index dcdce74c98..950ce4f253 100644 --- a/AdyenCard/Components/Stored Card/StoredCardAlertManager.swift +++ b/AdyenCard/Components/Stored Card/StoredCardAlertManager.swift @@ -115,10 +115,20 @@ internal final class StoredCardAlertManager: NSObject, UITextFieldDelegate, Adye let details = CardDetails(paymentMethod: paymentMethod, encryptedSecurityCode: encryptedSecurityCode) completionHandler?(.success(details)) } catch { + sendEncryptionErrorEvent() completionHandler?(.failure(error)) } } + private func sendEncryptionErrorEvent() { + var errorEvent = AnalyticsEventError( + component: paymentMethod.type.rawValue, + type: .internal + ) + errorEvent.code = AnalyticsConstants.ErrorCode.encryptionError.stringValue + context.analyticsProvider?.add(error: errorEvent) + } + // MARK: - UITextFieldDelegate @objc From 71d10eb41492841a29c84a9396a88ed5f682fd12 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Thu, 12 Dec 2024 14:56:56 +0100 Subject: [PATCH 07/13] 3ds2 related error events --- .../ThreeDS2CoreActionHandler.swift | 39 +++++--- ...DS2CompactActionHandler+Initializers.swift | 2 +- .../ThreeDS2CompactActionHandler.swift | 20 +++-- .../ThreeDS2FingerprintSubmitter.swift | 21 +++-- .../ThreeDS2FingerprintSubmitterTests.swift | 11 ++- .../ThreeDS2CompactActionHandlerTests.swift | 89 +++++++++++++++++-- 6 files changed, 145 insertions(+), 37 deletions(-) diff --git a/AdyenActions/Components/3DS2/Action handlers/3DS2 Without Delegated Authentication/ThreeDS2CoreActionHandler.swift b/AdyenActions/Components/3DS2/Action handlers/3DS2 Without Delegated Authentication/ThreeDS2CoreActionHandler.swift index 0dc958d52f..f3c12723c3 100644 --- a/AdyenActions/Components/3DS2/Action handlers/3DS2 Without Delegated Authentication/ThreeDS2CoreActionHandler.swift +++ b/AdyenActions/Components/3DS2/Action handlers/3DS2 Without Delegated Authentication/ThreeDS2CoreActionHandler.swift @@ -80,17 +80,18 @@ internal class ThreeDS2CoreActionHandler: AnyThreeDS2CoreActionHandler { completionHandler: @escaping (Result) -> Void ) { Analytics.sendEvent(event) - sendFingerPrintEvent(.fingerprintSent) + sendLogEvent(.fingerprintSent, for: Constants.fingerprintEvent) createFingerprint(fingerprintAction) { [weak self] result in guard let self else { return } - self.sendFingerPrintEvent(.fingerprintComplete) + self.sendLogEvent(.fingerprintComplete, for: Constants.fingerprintEvent) switch result { case let .success(encodedFingerprint): completionHandler(.success(encodedFingerprint)) case let .failure(error): + sendErrorEvent(.threeDS2FingerprintHandlingFailed, for: Constants.fingerprintEvent) self.didFail(with: error, completionHandler: completionHandler) } } @@ -116,6 +117,7 @@ internal class ThreeDS2CoreActionHandler: AnyThreeDS2CoreActionHandler { ) } } catch { + sendErrorEvent(.threeDS2DecodingFailed, for: Constants.fingerprintEvent) didFail(with: error, completionHandler: completionHandler) } } @@ -133,12 +135,15 @@ internal class ThreeDS2CoreActionHandler: AnyThreeDS2CoreActionHandler { completionHandler(.success(encodedFingerprint)) case let .failure(error): + sendErrorEvent(.threeDS2TransactionCreationFailed, for: Constants.fingerprintEvent) + let encodedError = try AdyenCoder.encodeBase64(ThreeDS2Component.Fingerprint( threeDS2SDKError: error.base64Representation()) ) completionHandler(.success(encodedError)) } } catch { + sendErrorEvent(.threeDS2FingerprintCreationFailed, for: Constants.fingerprintEvent) didFail(with: error, completionHandler: completionHandler) } } @@ -165,17 +170,19 @@ internal class ThreeDS2CoreActionHandler: AnyThreeDS2CoreActionHandler { completionHandler: @escaping (Result) -> Void ) { guard let transaction else { + sendErrorEvent(.threeDS2TransactionMissing, for: Constants.challengeEvent) return didFail(with: ThreeDS2Component.Error.missingTransaction, completionHandler: completionHandler) } Analytics.sendEvent(event) - sendChallengeEvent(.challengeDataSent) + sendLogEvent(.challengeDataSent, for: Constants.challengeEvent) let token: ThreeDS2Component.ChallengeToken do { token = try AdyenCoder.decodeBase64(challengeAction.challengeToken) as ThreeDS2Component.ChallengeToken } catch { + sendErrorEvent(.threeDS2DecodingFailed, for: Constants.challengeEvent) return didFail(with: error, completionHandler: completionHandler) } @@ -184,12 +191,12 @@ internal class ThreeDS2CoreActionHandler: AnyThreeDS2CoreActionHandler { threeDSRequestorAppURL: threeDSRequestorAppURL ?? token.threeDSRequestorAppURL ) - sendChallengeEvent(.challengeDisplayed) + sendLogEvent(.challengeDisplayed, for: Constants.challengeEvent) transaction.performChallenge(with: challengeParameters) { [weak self] challengeResult, error in guard let self else { return } - self.sendChallengeEvent(.challengeComplete) + self.sendLogEvent(.challengeComplete, for: Constants.challengeEvent) guard let result = challengeResult else { self.didReceiveErrorOnChallenge(error: error, challengeAction: challengeAction, completionHandler: completionHandler) @@ -220,11 +227,17 @@ internal class ThreeDS2CoreActionHandler: AnyThreeDS2CoreActionHandler { } switch (error.domain, error.code) { case (ADYRuntimeErrorDomain, Int(ADYRuntimeErrorCode.challengeCancelled.rawValue)): + sendErrorEvent( + .threeDS2ChallengeHandlingFailed, + for: Constants.challengeEvent, + message: "cancelled" + ) didFail( with: error, completionHandler: completionHandler ) default: + sendErrorEvent(.threeDS2ChallengeHandlingFailed, for: Constants.challengeEvent) didFinish( threeDS2SDKError: error.base64Representation(), authorizationToken: challengeAction.authorisationToken, @@ -283,22 +296,20 @@ internal class ThreeDS2CoreActionHandler: AnyThreeDS2CoreActionHandler { // MARK: - Events { - private func sendFingerPrintEvent(_ subtype: AnalyticsEventLog.LogSubType) { + private func sendLogEvent(_ subtype: AnalyticsEventLog.LogSubType, for component: String) { let logEvent = AnalyticsEventLog( - component: Constants.fingerprintEvent, + component: component, type: .threeDS2, subType: subtype ) context.analyticsProvider?.add(log: logEvent) } - private func sendChallengeEvent(_ subtype: AnalyticsEventLog.LogSubType) { - let logEvent = AnalyticsEventLog( - component: Constants.challengeEvent, - type: .threeDS2, - subType: subtype - ) - context.analyticsProvider?.add(log: logEvent) + private func sendErrorEvent(_ code: AnalyticsConstants.ErrorCode, for component: String, message: String? = nil) { + var errorEvent = AnalyticsEventError(component: component, type: .threeDS2) + errorEvent.code = code.stringValue + errorEvent.message = message + context.analyticsProvider?.add(error: errorEvent) } } diff --git a/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler+Initializers.swift b/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler+Initializers.swift index 0ef72ab0e9..06fc5d5d59 100644 --- a/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler+Initializers.swift +++ b/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler+Initializers.swift @@ -20,7 +20,7 @@ extension ThreeDS2CompactActionHandler { delegatedAuthenticationConfiguration: ThreeDS2Component.Configuration.DelegatedAuthentication? ) { - let fingerprintSubmitter = ThreeDS2FingerprintSubmitter(apiContext: context.apiContext) + let fingerprintSubmitter = ThreeDS2FingerprintSubmitter(context: context) self.init( context: context, fingerprintSubmitter: fingerprintSubmitter, diff --git a/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler.swift b/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler.swift index ef31eb9c8a..49a9e73278 100644 --- a/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler.swift +++ b/AdyenActions/Components/3DS2/Action handlers/ThreeDS2CompactActionHandler.swift @@ -11,6 +11,14 @@ import Foundation /// Handles the 3D Secure 2 fingerprint and challenge in one call using a `fingerprintSubmitter`. internal final class ThreeDS2CompactActionHandler: AnyThreeDS2ActionHandler, ComponentWrapper { + // MARK: - Private + + private let fingerprintSubmitter: AnyThreeDS2FingerprintSubmitter + + private let threeDS2EventName = "3ds2" + + // MARK: - Internal + internal weak var presentationDelegate: Adyen.PresentationDelegate? { didSet { coreActionHandler.presentationDelegate = presentationDelegate @@ -42,6 +50,8 @@ internal final class ThreeDS2CompactActionHandler: AnyThreeDS2ActionHandler, Com } } + internal var context: AdyenContext + /// Initializes the 3D Secure 2 action handler. /// /// - Parameter context: The context object for this component. @@ -57,12 +67,13 @@ internal final class ThreeDS2CompactActionHandler: AnyThreeDS2ActionHandler, Com coreActionHandler: AnyThreeDS2CoreActionHandler? = nil, delegatedAuthenticationConfiguration: ThreeDS2Component.Configuration.DelegatedAuthentication? = nil ) { + self.context = context self.coreActionHandler = coreActionHandler ?? createDefaultThreeDS2CoreActionHandler( context: context, appearanceConfiguration: appearanceConfiguration, delegatedAuthenticationConfiguration: delegatedAuthenticationConfiguration ) - self.fingerprintSubmitter = fingerprintSubmitter ?? ThreeDS2FingerprintSubmitter(apiContext: context.apiContext) + self.fingerprintSubmitter = fingerprintSubmitter ?? ThreeDS2FingerprintSubmitter(context: context) self.coreActionHandler.service = service } @@ -120,11 +131,4 @@ internal final class ThreeDS2CompactActionHandler: AnyThreeDS2ActionHandler, Com } } } - - // MARK: - Private - - private let fingerprintSubmitter: AnyThreeDS2FingerprintSubmitter - - private let threeDS2EventName = "3ds2" - } diff --git a/AdyenActions/Components/3DS2/Fingerprint Submitter/ThreeDS2FingerprintSubmitter.swift b/AdyenActions/Components/3DS2/Fingerprint Submitter/ThreeDS2FingerprintSubmitter.swift index 2ee5c82b2b..404702aa13 100644 --- a/AdyenActions/Components/3DS2/Fingerprint Submitter/ThreeDS2FingerprintSubmitter.swift +++ b/AdyenActions/Components/3DS2/Fingerprint Submitter/ThreeDS2FingerprintSubmitter.swift @@ -18,13 +18,17 @@ internal protocol AnyThreeDS2FingerprintSubmitter { internal final class ThreeDS2FingerprintSubmitter: AnyThreeDS2FingerprintSubmitter { + private enum Constants { + static let fingerprintEvent = "threeDS2Fingerprint" + } + private let apiClient: APIClientProtocol - private let apiContext: APIContext + private let context: AdyenContext - internal init(apiContext: APIContext, apiClient: APIClientProtocol? = nil) { - self.apiContext = apiContext - self.apiClient = apiClient ?? APIClient(apiContext: apiContext) + internal init(context: AdyenContext, apiClient: APIClientProtocol? = nil) { + self.context = context + self.apiClient = apiClient ?? APIClient(apiContext: context.apiContext) } internal func submit( @@ -34,7 +38,7 @@ internal final class ThreeDS2FingerprintSubmitter: AnyThreeDS2FingerprintSubmitt ) { let request = Submit3DS2FingerprintRequest( - clientKey: apiContext.clientKey, + clientKey: context.apiContext.clientKey, fingerprint: fingerprint, paymentData: paymentData ) @@ -52,7 +56,14 @@ internal final class ThreeDS2FingerprintSubmitter: AnyThreeDS2FingerprintSubmitt case let .success(response): completionHandler(.success(response.result)) case let .failure(error): + sendApiErrorEvent() completionHandler(.failure(error)) } } + + private func sendApiErrorEvent() { + var errorEvent = AnalyticsEventError(component: Constants.fingerprintEvent, type: .api) + errorEvent.code = AnalyticsConstants.ErrorCode.apiErrorThreeDS2.stringValue + context.analyticsProvider?.add(error: errorEvent) + } } diff --git a/Tests/IntegrationTests/Card Tests/3DS2 Component/3DS2 Fingerprint Submitter/ThreeDS2FingerprintSubmitterTests.swift b/Tests/IntegrationTests/Card Tests/3DS2 Component/3DS2 Fingerprint Submitter/ThreeDS2FingerprintSubmitterTests.swift index f0acc3959c..0d28431a05 100644 --- a/Tests/IntegrationTests/Card Tests/3DS2 Component/3DS2 Fingerprint Submitter/ThreeDS2FingerprintSubmitterTests.swift +++ b/Tests/IntegrationTests/Card Tests/3DS2 Component/3DS2 Fingerprint Submitter/ThreeDS2FingerprintSubmitterTests.swift @@ -4,6 +4,7 @@ // This file is open source and available under the MIT license. See the LICENSE file for more info. // +@_spi(AdyenInternal) @testable import Adyen @_spi(AdyenInternal) @testable import AdyenActions @testable @_spi(AdyenInternal) import AdyenCard import XCTest @@ -24,7 +25,7 @@ class ThreeDS2FingerprintSubmitterTests: XCTestCase { func testRedirect() throws { let apiClient = APIClientMock() - let sut = ThreeDS2FingerprintSubmitter(apiContext: Dummy.apiContext, apiClient: apiClient) + let sut = ThreeDS2FingerprintSubmitter(context: Dummy.context, apiClient: apiClient) let mockedRedirectAction = RedirectAction(url: URL(string: "https://www.adyen.com")!, paymentData: "data") let mockedAction = Action.redirect(mockedRedirectAction) @@ -57,7 +58,7 @@ class ThreeDS2FingerprintSubmitterTests: XCTestCase { func testThreeDSChallenge() throws { let apiClient = APIClientMock() - let sut = ThreeDS2FingerprintSubmitter(apiContext: Dummy.apiContext, apiClient: apiClient) + let sut = ThreeDS2FingerprintSubmitter(context: Dummy.context, apiClient: apiClient) let mockedChallengeAction = ThreeDS2ChallengeAction(challengeToken: "token", authorisationToken: "authToken", paymentData: "data") let mockedAction = Action.threeDS2Challenge(mockedChallengeAction) @@ -90,7 +91,7 @@ class ThreeDS2FingerprintSubmitterTests: XCTestCase { func testNoAction() throws { let apiClient = APIClientMock() - let sut = ThreeDS2FingerprintSubmitter(apiContext: Dummy.apiContext, apiClient: apiClient) + let sut = ThreeDS2FingerprintSubmitter(context: Dummy.context, apiClient: apiClient) let mockedDetails = ThreeDS2Details.completed(ThreeDSResult(payload: "payload")) let mockedResponse = Submit3DS2FingerprintResponse(result: .details(mockedDetails)) @@ -124,7 +125,8 @@ class ThreeDS2FingerprintSubmitterTests: XCTestCase { func testFailure() throws { let apiClient = APIClientMock() - let sut = ThreeDS2FingerprintSubmitter(apiContext: Dummy.apiContext, apiClient: apiClient) + let analyticsProviderMock = AnalyticsProviderMock() + let sut = ThreeDS2FingerprintSubmitter(context: Dummy.context(with: analyticsProviderMock), apiClient: apiClient) apiClient.mockedResults = [.failure(Dummy.error)] @@ -134,6 +136,7 @@ class ThreeDS2FingerprintSubmitterTests: XCTestCase { switch result { case let .failure(error): XCTAssertEqual(error as? Dummy, Dummy.error) + XCTAssertEqual(analyticsProviderMock.errors[0].errorType, .api) case .success: XCTFail() } diff --git a/Tests/IntegrationTests/Card Tests/3DS2 Component/ThreeDS2CompactActionHandlerTests.swift b/Tests/IntegrationTests/Card Tests/3DS2 Component/ThreeDS2CompactActionHandlerTests.swift index 6696012aab..3a65973bdc 100644 --- a/Tests/IntegrationTests/Card Tests/3DS2 Component/ThreeDS2CompactActionHandlerTests.swift +++ b/Tests/IntegrationTests/Card Tests/3DS2 Component/ThreeDS2CompactActionHandlerTests.swift @@ -77,12 +77,32 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { ) let resultExpectation = expectation(description: "Expect ThreeDS2ActionHandler completion closure to be called.") - let sut = ThreeDS2CompactActionHandler(context: Dummy.context, fingerprintSubmitter: submitter, service: service) + let analyticsProviderMock = AnalyticsProviderMock() + let sut = ThreeDS2CompactActionHandler( + context: Dummy.context(with: analyticsProviderMock), + fingerprintSubmitter: submitter, + service: service + ) sut.handle(fingerprintAction) { result in switch result { case .success: XCTFail() case let .failure(error): + let errorEvent1 = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent1.errorType, .threeDS2) + XCTAssertEqual(errorEvent1.component, "threeDS2Fingerprint") + XCTAssertEqual( + errorEvent1.code, + AnalyticsConstants.ErrorCode.threeDS2DecodingFailed.stringValue + ) + + let errorEvent2 = analyticsProviderMock.errors[1] + XCTAssertEqual(errorEvent2.errorType, .threeDS2) + XCTAssertEqual(errorEvent2.component, "threeDS2Fingerprint") + XCTAssertEqual( + errorEvent2.code, + AnalyticsConstants.ErrorCode.threeDS2FingerprintHandlingFailed.stringValue + ) let decodingError = error as? DecodingError switch decodingError { case .dataCorrupted?: () @@ -164,7 +184,12 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { completion(nil, Dummy.error) } - let sut = ThreeDS2CompactActionHandler(context: Dummy.context, fingerprintSubmitter: submitter, service: service) + let analyticsProviderMock = AnalyticsProviderMock() + let sut = ThreeDS2CompactActionHandler( + context: Dummy.context(with: analyticsProviderMock), + fingerprintSubmitter: submitter, + service: service + ) sut.transaction = mockedTransaction let resultExpectation = expectation(description: "Expect ThreeDS2ActionHandler completion closure to be called.") @@ -181,6 +206,14 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { // Check if there is a threeDS2SDKError in the payload. let payload: Payload? = try? AdyenCoder.decodeBase64(threeDSResult.payload) XCTAssertNotNil(payload?.threeDS2SDKError) + + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.errorType, .threeDS2) + XCTAssertEqual(errorEvent.component, "threeDS2Challenge") + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.threeDS2ChallengeHandlingFailed.stringValue + ) default: XCTFail() } @@ -209,7 +242,12 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { completion(nil, Dummy.error) } - let sut = ThreeDS2CompactActionHandler(context: Dummy.context, fingerprintSubmitter: submitter, service: service) + let analyticsProviderMock = AnalyticsProviderMock() + let sut = ThreeDS2CompactActionHandler( + context: Dummy.context(with: analyticsProviderMock), + fingerprintSubmitter: submitter, + service: service + ) sut.transaction = mockedTransaction let resultExpectation = expectation(description: "Expect ThreeDS2ActionHandler completion closure to be called.") @@ -218,6 +256,13 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { case .success: XCTFail() case let .failure(error): + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.errorType, .threeDS2) + XCTAssertEqual(errorEvent.component, "threeDS2Challenge") + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.threeDS2DecodingFailed.stringValue + ) let decodingError = error as? DecodingError switch decodingError { case .dataCorrupted?: () @@ -236,7 +281,12 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { let service = AnyADYServiceMock() - let sut = ThreeDS2CompactActionHandler(context: Dummy.context, fingerprintSubmitter: submitter, service: service) + let analyticsProviderMock = AnalyticsProviderMock() + let sut = ThreeDS2CompactActionHandler( + context: Dummy.context(with: analyticsProviderMock), + fingerprintSubmitter: submitter, + service: service + ) let resultExpectation = expectation(description: "Expect ThreeDS2ActionHandler completion closure to be called.") sut.handle(challengeAction) { result in @@ -244,6 +294,13 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { case .success: XCTFail() case let .failure(error): + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.errorType, .threeDS2) + XCTAssertEqual(errorEvent.component, "threeDS2Challenge") + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.threeDS2TransactionMissing.stringValue + ) let decodingError = error as? ThreeDS2Component.Error switch decodingError { @@ -275,12 +332,34 @@ class ThreeDS2CompactActionHandlerTests: XCTestCase { ) let resultExpectation = expectation(description: "Expect ThreeDS2ActionHandler completion closure to be called.") - let sut = ThreeDS2CompactActionHandler(context: Dummy.context, fingerprintSubmitter: submitter, service: service) + + let analyticsProviderMock = AnalyticsProviderMock() + let sut = ThreeDS2CompactActionHandler( + context: Dummy.context(with: analyticsProviderMock), + fingerprintSubmitter: submitter, + service: service + ) sut.handle(fingerprintAction) { result in switch result { case .success: XCTFail() case let .failure(error): + let errorEvent1 = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent1.errorType, .threeDS2) + XCTAssertEqual(errorEvent1.component, "threeDS2Fingerprint") + XCTAssertEqual( + errorEvent1.code, + AnalyticsConstants.ErrorCode.threeDS2FingerprintCreationFailed.stringValue + ) + + let errorEvent2 = analyticsProviderMock.errors[1] + XCTAssertEqual(errorEvent2.errorType, .threeDS2) + XCTAssertEqual(errorEvent2.component, "threeDS2Fingerprint") + XCTAssertEqual( + errorEvent2.code, + AnalyticsConstants.ErrorCode.threeDS2FingerprintHandlingFailed.stringValue + ) + let decodingError = error as? DecodingError switch decodingError { case .dataCorrupted?: () From 7b84586e734417f0ff0d6133d851b07feb95a701 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Tue, 17 Dec 2024 17:05:26 +0100 Subject: [PATCH 08/13] add redirect action name --- AdyenActions/Actions/RedirectAction.swift | 10 +++++++++- .../Components/Redirect/RedirectComponent.swift | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AdyenActions/Actions/RedirectAction.swift b/AdyenActions/Actions/RedirectAction.swift index 9693c56bfc..85dc40ea9f 100644 --- a/AdyenActions/Actions/RedirectAction.swift +++ b/AdyenActions/Actions/RedirectAction.swift @@ -18,15 +18,23 @@ public struct RedirectAction: Decodable { /// Native redirect data. public let nativeRedirectData: String? + internal let paymentMethodType: String? + /// Initializes a redirect action. /// /// - Parameters: /// - url: The URL to which to redirect the user. /// - paymentData: The server-generated payment data that should be submitted to the `/payments/details` endpoint. /// - nativeRedirectData: Native redirect data. - public init(url: URL, paymentData: String?, nativeRedirectData: String? = nil) { + public init( + url: URL, + paymentData: String?, + nativeRedirectData: String? = nil, + paymentMethodType: String? = nil + ) { self.url = url self.paymentData = paymentData self.nativeRedirectData = nativeRedirectData + self.paymentMethodType = paymentMethodType } } diff --git a/AdyenActions/Components/Redirect/RedirectComponent.swift b/AdyenActions/Components/Redirect/RedirectComponent.swift index 59d58cb234..e65a723635 100644 --- a/AdyenActions/Components/Redirect/RedirectComponent.swift +++ b/AdyenActions/Components/Redirect/RedirectComponent.swift @@ -69,6 +69,8 @@ public final class RedirectComponent: ActionComponent { /// The component configurations. public var configuration: Configuration + private var action: RedirectAction? + /// Initializes the component. /// /// - Parameter context: The context object for this component. @@ -100,6 +102,8 @@ public final class RedirectComponent: ActionComponent { context: context.apiContext ) + self.action = action + if action.url.adyen.isHttp { openHttpSchemeUrl(action) } else { @@ -207,6 +211,7 @@ public final class RedirectComponent: ActionComponent { } private func sendErrorEvent(for type: AnalyticsEventError.ErrorType, code: AnalyticsConstants.ErrorCode) { + let componentName = action?.paymentMethodType ?? configuration.componentName var errorEvent = AnalyticsEventError( component: configuration.componentName, type: type From b858ef79051138f42052dea56e05cb826bec3f21 Mon Sep 17 00:00:00 2001 From: "A. Eren Besel" Date: Wed, 18 Dec 2024 16:31:02 +0100 Subject: [PATCH 09/13] Update AdyenActions/Actions/RedirectAction.swift Co-authored-by: Alex Guretzki --- AdyenActions/Actions/RedirectAction.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/AdyenActions/Actions/RedirectAction.swift b/AdyenActions/Actions/RedirectAction.swift index 85dc40ea9f..ec452da757 100644 --- a/AdyenActions/Actions/RedirectAction.swift +++ b/AdyenActions/Actions/RedirectAction.swift @@ -26,6 +26,7 @@ public struct RedirectAction: Decodable { /// - url: The URL to which to redirect the user. /// - paymentData: The server-generated payment data that should be submitted to the `/payments/details` endpoint. /// - nativeRedirectData: Native redirect data. + /// - paymentMethodType: The type of the payment method. public init( url: URL, paymentData: String?, From 974a19b3de2bb1e870802aa1a1bc7295e9526ffe Mon Sep 17 00:00:00 2001 From: erenbesel Date: Thu, 19 Dec 2024 10:57:04 +0100 Subject: [PATCH 10/13] a few more tests --- .../Redirect/RedirectComponent.swift | 10 +++---- .../Redirect/RedirectComponentTests.swift | 26 ++++++++++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/AdyenActions/Components/Redirect/RedirectComponent.swift b/AdyenActions/Components/Redirect/RedirectComponent.swift index e65a723635..22a03dc596 100644 --- a/AdyenActions/Components/Redirect/RedirectComponent.swift +++ b/AdyenActions/Components/Redirect/RedirectComponent.swift @@ -161,7 +161,7 @@ public final class RedirectComponent: ActionComponent { self.registerRedirectBounceBackListener(action) self.delegate?.didOpenExternalApplication(component: self) } else { - self.sendErrorEvent(for: .redirect, code: .redirectFailed) + self.sendErrorEvent(.redirectFailed, type: .redirect) self.delegate?.didFail(with: RedirectComponent.Error.appNotFound, from: self) } } @@ -186,7 +186,7 @@ public final class RedirectComponent: ActionComponent { private func handleNativeMobileRedirect(withReturnURL returnURL: URL, redirectStateData: String, _ action: RedirectAction) throws { guard let queryString = returnURL.query else { - sendErrorEvent(for: .redirect, code: .redirectParseFailed) + sendErrorEvent(.redirectParseFailed, type: .redirect) throw Error.invalidRedirectParameters } let request = NativeRedirectResultRequest( @@ -197,7 +197,7 @@ public final class RedirectComponent: ActionComponent { guard let self else { return } switch result { case let .failure(error): - self.sendErrorEvent(for: .api, code: .apiErrorNativeRedirect) + self.sendErrorEvent(.apiErrorNativeRedirect, type: .api) self.delegate?.didFail(with: error, from: self) case let .success(response): self.notifyDelegateDidProvide(redirectDetails: response, action) @@ -210,10 +210,10 @@ public final class RedirectComponent: ActionComponent { delegate?.didProvide(actionData, from: self) } - private func sendErrorEvent(for type: AnalyticsEventError.ErrorType, code: AnalyticsConstants.ErrorCode) { + private func sendErrorEvent(_ code: AnalyticsConstants.ErrorCode, type: AnalyticsEventError.ErrorType) { let componentName = action?.paymentMethodType ?? configuration.componentName var errorEvent = AnalyticsEventError( - component: configuration.componentName, + component: componentName, type: type ) errorEvent.code = code.stringValue diff --git a/Tests/IntegrationTests/Actions Tests/Redirect/RedirectComponentTests.swift b/Tests/IntegrationTests/Actions Tests/Redirect/RedirectComponentTests.swift index fc7ac99619..7c151846af 100644 --- a/Tests/IntegrationTests/Actions Tests/Redirect/RedirectComponentTests.swift +++ b/Tests/IntegrationTests/Actions Tests/Redirect/RedirectComponentTests.swift @@ -73,7 +73,8 @@ class RedirectComponentTests: XCTestCase { } func testOpenCustomSchemeFailure() { - let sut = RedirectComponent(context: Dummy.context) + let analyticsProviderMock = AnalyticsProviderMock() + let sut = RedirectComponent(context: Dummy.context(with: analyticsProviderMock)) let delegate = ActionComponentDelegateMock() sut.delegate = delegate let appLauncher = AppLauncherMock() @@ -94,13 +95,21 @@ class RedirectComponentTests: XCTestCase { XCTFail("delegate.didOpenExternalApplication() must not to be called") } + let testPaymentMethodName = "testRedirectPaymentMethod" delegate.onDidFail = { error, component in + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.errorType, .redirect) + XCTAssertEqual(errorEvent.component, testPaymentMethodName) + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.redirectFailed.stringValue + ) XCTAssertTrue(error is RedirectComponent.Error) XCTAssertEqual(error as! RedirectComponent.Error, RedirectComponent.Error.appNotFound) XCTAssertTrue(component === sut) } - let action = RedirectAction(url: URL(string: "bla://")!, paymentData: "test_data") + let action = RedirectAction(url: URL(string: "bla://")!, paymentData: "test_data", paymentMethodType: testPaymentMethodName) sut.handle(action) waitForExpectations(timeout: 10, handler: nil) @@ -320,7 +329,11 @@ class RedirectComponentTests: XCTestCase { func testNativeRedirectEndpointCallFails() { let apiClient = APIClientMock() - let sut = RedirectComponent(context: Dummy.context, apiClient: apiClient.retryAPIClient(with: SimpleScheduler(maximumCount: 2))) + let analyticsProviderMock = AnalyticsProviderMock() + let sut = RedirectComponent( + context: Dummy.context(with: analyticsProviderMock), + apiClient: apiClient.retryAPIClient(with: SimpleScheduler(maximumCount: 2)) + ) apiClient.mockedResults = [.failure(Dummy.error)] let appLauncher = AppLauncherMock() @@ -339,6 +352,13 @@ class RedirectComponentTests: XCTestCase { XCTFail("Should not call onDidProvide") } delegate.onDidFail = { error, _ in + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.errorType, .api) + XCTAssertEqual(errorEvent.component, "redirect") + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.apiErrorNativeRedirect.stringValue + ) XCTAssertEqual(error as! Dummy, .error) redirectExpectation.fulfill() } From 228267e524625e0619b8e6c08f0db9865c071f64 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Tue, 31 Dec 2024 13:37:40 +0100 Subject: [PATCH 11/13] twint/cashapp error events --- .../SDK/TwintSDKActionComponent.swift | 29 ++- AdyenCashAppPay/CashAppPayComponent.swift | 35 +++- .../TwintSDKActionTests+Convenience.swift | 5 +- .../Twint/TwintSDKActionTests.swift | 10 + .../CashAppPayComponentTests.swift | 172 +++++++++++++++++- 5 files changed, 236 insertions(+), 15 deletions(-) diff --git a/AdyenActions/Components/SDK/TwintSDKActionComponent.swift b/AdyenActions/Components/SDK/TwintSDKActionComponent.swift index 73c87e5d81..3278ac8d42 100644 --- a/AdyenActions/Components/SDK/TwintSDKActionComponent.swift +++ b/AdyenActions/Components/SDK/TwintSDKActionComponent.swift @@ -117,7 +117,10 @@ import Foundation .twintNoAppsInstalledMessage, self.configuration.localizationParameters ) - self.handleShowError(errorMessage) + self.handleShowError( + errorMessage, + componentName: action.paymentMethodType + ) return } @@ -133,7 +136,10 @@ import Foundation let completionHandler: (Error?) -> Void = { [weak self] error in guard let self else { return } if let error { - self.handleShowError(error.localizedDescription) + self.handleShowError( + error.localizedDescription, + componentName: action.paymentMethodType + ) return } @@ -212,10 +218,14 @@ import Foundation presentationDelegate.present(component: presentableComponent) } - private func handleShowError(_ error: String) { + private func handleShowError(_ errorMessage: String, componentName: String) { + sendThirdPartyErrorEvent( + with: errorMessage, + componentName: componentName + ) let alert = UIAlertController( title: nil, - message: error, + message: errorMessage, preferredStyle: .alert ) alert.addAction( @@ -235,6 +245,17 @@ import Foundation private func cleanup() { pollingComponent?.didCancel() } + + private func sendThirdPartyErrorEvent(with message: String?, componentName: String) { + var errorEvent = AnalyticsEventError( + component: componentName, + type: .thirdParty + ) + errorEvent.code = AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + errorEvent.message = message + + context.analyticsProvider?.add(error: errorEvent) + } } @_spi(AdyenInternal) diff --git a/AdyenCashAppPay/CashAppPayComponent.swift b/AdyenCashAppPay/CashAppPayComponent.swift index 2645c1c65b..09a5aa5b5d 100644 --- a/AdyenCashAppPay/CashAppPayComponent.swift +++ b/AdyenCashAppPay/CashAppPayComponent.swift @@ -23,6 +23,12 @@ public final class CashAppPayComponent: PaymentComponent, static let storeDetailsItem = "storeDetailsItem" static let cashAppButtonItem = "cashAppButtonItem" } + + private enum ErrorMessage { + static let unexpectedError = "CashApp unexpected error" + static let apiError = "CashApp api error" + static let integrationError = "CashApp integration error" + } /// The context object for this component. @_spi(AdyenInternal) @@ -209,7 +215,7 @@ public final class CashAppPayComponent: PaymentComponent, storePaymentMethod: storePayment )) } catch { - fail(with: error) + fail(with: error, message: error.localizedDescription) } } @@ -225,24 +231,37 @@ extension CashAppPayComponent: CashAppPayObserver { case let .approved(request, grants): submitApprovedRequest(with: grants, profile: request.customerProfile) case let .apiError(error): - fail(with: error) + fail(with: error, message: ErrorMessage.apiError) case let .networkError(error): - fail(with: error) + fail(with: error, message: error.localizedDescription) case let .unexpectedError(error): - fail(with: error) + fail(with: error, message: ErrorMessage.unexpectedError) case let .integrationError(error): - fail(with: error) + fail(with: error, message: ErrorMessage.integrationError) case .declined: - fail(with: Error.declined) + let error = Error.declined + fail(with: error, message: error.localizedDescription) default: break } } - private func fail(with error: Swift.Error) { + private func fail(with error: Swift.Error, message: String? = nil) { stopLoading() + sendThirdPartyErrorEvent(with: message) delegate?.didFail(with: error, from: self) } + + private func sendThirdPartyErrorEvent(with message: String?) { + var errorEvent = AnalyticsEventError( + component: paymentMethod.type.rawValue, + type: .thirdParty + ) + errorEvent.code = AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + errorEvent.message = message + + context.analyticsProvider?.add(error: errorEvent) + } } @available(iOS 13.0, *) @@ -255,7 +274,7 @@ extension CashAppPayComponent { /// Payment was declined by the Cash App Pay app. case declined - public var errorDescription: String? { + public var localizedDescription: String? { switch self { case .noGrant: return "There was no grant object in the customer request." diff --git a/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests+Convenience.swift b/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests+Convenience.swift index 22dc064cb9..d30db0c664 100644 --- a/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests+Convenience.swift +++ b/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests+Convenience.swift @@ -50,6 +50,7 @@ import XCTest static func actionComponent( with twintSpy: TwintSpy, configuration: TwintSDKActionComponent.Configuration = .dummy, + context: AdyenContext = Dummy.context, presentationDelegate: PresentationDelegate?, delegate: ActionComponentDelegate?, shouldFailPolling: Bool = false @@ -64,7 +65,7 @@ import XCTest } let component = TwintSDKActionComponent( - context: Dummy.context, + context: context, configuration: configuration, twint: twintSpy, pollingComponentBuilder: pollingBuilder @@ -114,7 +115,7 @@ import XCTest return actionComponentDelegateMock } - /// ActionComponentDelegateMock that fails when `onDidFail` is called + /// ActionComponentDelegateMock that fails when `onDidProvide` is called static func failureFlowActionComponentDelegateMock( onDidFail: @escaping (Error) -> Void ) -> ActionComponentDelegateMock { diff --git a/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests.swift b/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests.swift index d8f16b236b..51736fcce7 100644 --- a/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests.swift +++ b/Tests/IntegrationTests/Actions Tests/ActionComponent/Twint/TwintSDKActionTests.swift @@ -243,15 +243,25 @@ import XCTest return false } + let analyticsProviderMock = AnalyticsProviderMock() let presentationDelegate = PresentationDelegateMock() + presentationDelegate.doPresent = { component in let alertController = try XCTUnwrap(component.viewController as? UIAlertController) XCTAssertEqual(alertController.message, expectedAlertMessage) + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.component, "paymentMethodType") + XCTAssertEqual(errorEvent.errorType, .thirdParty) + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + ) alertExpectation.fulfill() } let twintActionComponent = Self.actionComponent( with: twintSpy, + context: Dummy.context(with: analyticsProviderMock), presentationDelegate: presentationDelegate, delegate: nil ) diff --git a/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift b/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift index cd7fdb9303..37110f8995 100644 --- a/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift +++ b/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift @@ -15,6 +15,12 @@ import XCTest final class CashAppPayComponentTests: XCTestCase { + private enum ErrorOption { + case apiError(PayKit.APIError) + case integrationError(PayKit.IntegrationError) + case networkError(PayKit.NetworkError) + } + var paymentMethodString = """ { "configuration" : { @@ -25,10 +31,24 @@ import XCTest "type" : "cashapp" } """ - + lazy var paymentMethod: CashAppPayPaymentMethod = { try! JSONDecoder().decode(CashAppPayPaymentMethod.self, from: paymentMethodString.data(using: .utf8)!) }() + + private static var integrationError: PayKit.IntegrationError = .init( + category: .MERCHANT_ERROR, + code: .BRAND_NOT_FOUND, + detail: "integrationError", + field: "error" + ) + + private static var apiError: PayKit.APIError = .init( + category: .API_ERROR, + code: .GATEWAY_TIMEOUT, + detail: "apiError", + field: nil + ) var context: AdyenContext! @@ -280,6 +300,156 @@ import XCTest XCTAssertEqual(paymentDelegateMock.didSubmitCallsCount, 1) } + func testSubmitFailure() throws { + let analyticsProviderMock = AnalyticsProviderMock() + let config = CashAppPayConfiguration(redirectURL: URL(string: "test")!) + let sut = CashAppPayComponent( + paymentMethod: paymentMethod, + context: Dummy.context(with: analyticsProviderMock), + configuration: config + ) + + setupRootViewController(sut.viewController) + + let paymentDelegateMock = PaymentComponentDelegateMock() + sut.delegate = paymentDelegateMock + + let failureExpectation = expectation(description: "didFail must be called when submitting fails.") + paymentDelegateMock.onDidFail = { _, _ in + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.component, "cashapp") + XCTAssertEqual(errorEvent.errorType, .thirdParty) + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + ) + failureExpectation.fulfill() + } + + sut.submitApprovedRequest(with: [], profile: .init(id: "test", cashtag: "test")) + wait(for: [failureExpectation], timeout: 5) + } + + func testIntegrationError() throws { + let analyticsProviderMock = AnalyticsProviderMock() + let config = CashAppPayConfiguration(redirectURL: URL(string: "test")!) + let sut = CashAppPayComponent( + paymentMethod: paymentMethod, + context: Dummy.context(with: analyticsProviderMock), + configuration: config + ) + + let paymentDelegateMock = PaymentComponentDelegateMock() + sut.delegate = paymentDelegateMock + + let errorExpectation = expectation(description: "should fail with integration error") + + paymentDelegateMock.onDidFail = { _, _ in + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.component, "cashapp") + XCTAssertEqual(errorEvent.errorType, .thirdParty) + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + ) + XCTAssertEqual(errorEvent.message, "CashApp integration error") + errorExpectation.fulfill() + } + + sut.stateDidChange(to: .integrationError(Self.integrationError)) + wait(for: [errorExpectation], timeout: 5) + } + + func testApiError() throws { + let analyticsProviderMock = AnalyticsProviderMock() + let config = CashAppPayConfiguration(redirectURL: URL(string: "test")!) + let sut = CashAppPayComponent( + paymentMethod: paymentMethod, + context: Dummy.context(with: analyticsProviderMock), + configuration: config + ) + + let paymentDelegateMock = PaymentComponentDelegateMock() + sut.delegate = paymentDelegateMock + + let errorExpectation = expectation(description: "should fail with integration error") + + paymentDelegateMock.onDidFail = { _, _ in + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.component, "cashapp") + XCTAssertEqual(errorEvent.errorType, .thirdParty) + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + ) + XCTAssertEqual(errorEvent.message, "CashApp api error") + errorExpectation.fulfill() + } + + sut.stateDidChange(to: .apiError(Self.apiError)) + wait(for: [errorExpectation], timeout: 5) + } + + func testUnexpectedError() throws { + let analyticsProviderMock = AnalyticsProviderMock() + let config = CashAppPayConfiguration(redirectURL: URL(string: "test")!) + let sut = CashAppPayComponent( + paymentMethod: paymentMethod, + context: Dummy.context(with: analyticsProviderMock), + configuration: config + ) + + let paymentDelegateMock = PaymentComponentDelegateMock() + sut.delegate = paymentDelegateMock + + let errorExpectation = expectation(description: "should fail with integration error") + + paymentDelegateMock.onDidFail = { _, _ in + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.component, "cashapp") + XCTAssertEqual(errorEvent.errorType, .thirdParty) + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + ) + XCTAssertEqual(errorEvent.message, "CashApp unexpected error") + errorExpectation.fulfill() + } + + sut.stateDidChange(to: .unexpectedError(.emptyErrorArray)) + wait(for: [errorExpectation], timeout: 5) + } + + func testNetworkError() throws { + let analyticsProviderMock = AnalyticsProviderMock() + let config = CashAppPayConfiguration(redirectURL: URL(string: "test")!) + let sut = CashAppPayComponent( + paymentMethod: paymentMethod, + context: Dummy.context(with: analyticsProviderMock), + configuration: config + ) + + let paymentDelegateMock = PaymentComponentDelegateMock() + sut.delegate = paymentDelegateMock + + let errorExpectation = expectation(description: "should fail with integration error") + + paymentDelegateMock.onDidFail = { _, _ in + let errorEvent = analyticsProviderMock.errors[0] + XCTAssertEqual(errorEvent.component, "cashapp") + XCTAssertEqual(errorEvent.errorType, .thirdParty) + XCTAssertEqual( + errorEvent.code, + AnalyticsConstants.ErrorCode.thirdPartyError.stringValue + ) + XCTAssertNotNil(errorEvent.message) + errorExpectation.fulfill() + } + + sut.stateDidChange(to: .networkError(.noResponse)) + wait(for: [errorExpectation], timeout: 5) + } + func testValidateShouldReturnFormViewControllerValidateResult() throws { // Given let configuration = CashAppPayConfiguration(redirectURL: URL(string: "test")!, showsSubmitButton: false) From deeaf2692a05b28de8782588c205141c510ffa02 Mon Sep 17 00:00:00 2001 From: erenbesel Date: Fri, 3 Jan 2025 11:06:31 +0100 Subject: [PATCH 12/13] add errordesc back on cashapp error --- AdyenCashAppPay/CashAppPayComponent.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AdyenCashAppPay/CashAppPayComponent.swift b/AdyenCashAppPay/CashAppPayComponent.swift index 09a5aa5b5d..4b2051ecf3 100644 --- a/AdyenCashAppPay/CashAppPayComponent.swift +++ b/AdyenCashAppPay/CashAppPayComponent.swift @@ -274,6 +274,8 @@ extension CashAppPayComponent { /// Payment was declined by the Cash App Pay app. case declined + public var errorDescription: String? { localizedDescription } + public var localizedDescription: String? { switch self { case .noGrant: From a8f939548615ccd85c735a428476333ef909fc8c Mon Sep 17 00:00:00 2001 From: erenbesel Date: Fri, 3 Jan 2025 14:00:46 +0100 Subject: [PATCH 13/13] revert localzied desc addition --- AdyenCashAppPay/CashAppPayComponent.swift | 4 +--- .../Cash App Pay/CashAppPayComponentTests.swift | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/AdyenCashAppPay/CashAppPayComponent.swift b/AdyenCashAppPay/CashAppPayComponent.swift index 4b2051ecf3..cddb21e9cb 100644 --- a/AdyenCashAppPay/CashAppPayComponent.swift +++ b/AdyenCashAppPay/CashAppPayComponent.swift @@ -274,9 +274,7 @@ extension CashAppPayComponent { /// Payment was declined by the Cash App Pay app. case declined - public var errorDescription: String? { localizedDescription } - - public var localizedDescription: String? { + public var errorDescription: String? { switch self { case .noGrant: return "There was no grant object in the customer request." diff --git a/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift b/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift index 37110f8995..3408011e80 100644 --- a/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift +++ b/Tests/IntegrationTests/Components Tests/Cash App Pay/CashAppPayComponentTests.swift @@ -323,6 +323,7 @@ import XCTest errorEvent.code, AnalyticsConstants.ErrorCode.thirdPartyError.stringValue ) + XCTAssertEqual(errorEvent.message, "There was no grant object in the customer request.") failureExpectation.fulfill() }