Skip to content

Commit

Permalink
[App Switch] Add analytic events (#1111)
Browse files Browse the repository at this point in the history
* Add app switch event names

* Add linkType property

* Change methods access level to be used by PayPalClient

* Use PayPalInternalClients to validate link type

* Add APP_SWTCH_STARTED event

* Add success and failure events for app switch flow

* Fix commented code

* Change demo paypal webview fragment input type

* Add additional conversion event

* Move lnkType setup and add handleReturnStarted event

* Fix lint

* Fix lint

* Remove unnecessary event failure call

* Move handle return event

* Add some tests

* Add tests

* Address PR feedback

* Rename LinkType cases

* Add canceled event

* Change failed to canceled, fix tests

* Update PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalClient.kt

Co-authored-by: Jax DesMarais-Leder <[email protected]>

---------

Co-authored-by: Jax DesMarais-Leder <[email protected]>
  • Loading branch information
richherrera and jaxdesmarais authored Sep 24, 2024
1 parent 75e34a8 commit 383917b
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ import androidx.annotation.RestrictTo
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
enum class LinkType(val stringValue: String) {
UNIVERSAL("universal"),
DEEPLINK("deeplink")
APP_SWITCH("universal"),
APP_LINK("deeplink")
}
3 changes: 2 additions & 1 deletion Demo/src/main/res/layout/fragment_paypal.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/buyer_email_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:inputType="textEmailAddress"/>

</com.google.android.material.textfield.TextInputLayout>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ internal object PayPalAnalytics {
const val TOKENIZATION_FAILED = "paypal:tokenize:failed"
const val TOKENIZATION_SUCCEEDED = "paypal:tokenize:succeeded"
const val BROWSER_LOGIN_CANCELED = "paypal:tokenize:browser-login:canceled"

// Additional Conversion events
const val HANDLE_RETURN_STARTED = "paypal:tokenize:handle-return:started"

// App Switch events
const val APP_SWITCH_STARTED = "paypal:tokenize:app-switch:started"
const val APP_SWITCH_SUCCEEDED = "paypal:tokenize:app-switch:succeeded"
const val APP_SWITCH_FAILED = "paypal:tokenize:app-switch:failed"
const val APP_SWITCH_CANCELED = "paypal:tokenize:app-switch:canceled"
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.braintreepayments.api.core.BraintreeClient
import com.braintreepayments.api.core.BraintreeException
import com.braintreepayments.api.core.BraintreeRequestCodes
import com.braintreepayments.api.core.Configuration
import com.braintreepayments.api.core.LinkType
import com.braintreepayments.api.core.UserCanceledException
import com.braintreepayments.api.paypal.PayPalPaymentIntent.Companion.fromString
import com.braintreepayments.api.sharedutils.Json
Expand All @@ -29,6 +30,11 @@ class PayPalClient internal constructor(
*/
private var payPalContextId: String? = null

/**
* Used for sending the type of flow, universal vs deeplink to FPTI
*/
private var linkType: LinkType? = null

/**
* True if `tokenize()` was called with a Vault request object type
*/
Expand Down Expand Up @@ -93,8 +99,17 @@ class PayPalClient internal constructor(
error: Exception? ->
if (payPalResponse != null) {
payPalContextId = payPalResponse.pairingId
val isAppSwitchFlow = internalPayPalClient.isAppSwitchEnabled(payPalRequest) &&
internalPayPalClient.isPayPalInstalled(context)
linkType = if (isAppSwitchFlow) LinkType.APP_SWITCH else LinkType.APP_LINK

try {
payPalResponse.browserSwitchOptions = buildBrowserSwitchOptions(payPalResponse)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_STARTED, analyticsParams)
}

callback.onPayPalPaymentAuthRequest(
PayPalPaymentAuthRequest.ReadyToLaunch(payPalResponse)
)
Expand Down Expand Up @@ -164,9 +179,17 @@ class PayPalClient internal constructor(
val approvalUrl = Json.optString(metadata, "approval-url", null)
val successUrl = Json.optString(metadata, "success-url", null)
val paymentType = Json.optString(metadata, "payment-type", "unknown")

val isBillingAgreement = paymentType.equals("billing-agreement", ignoreCase = true)
val tokenKey = if (isBillingAgreement) "ba_token" else "token"
val switchInitiatedTime = Uri.parse(approvalUrl).getQueryParameter("switch_initiated_time")
val isAppSwitchFlow = !switchInitiatedTime.isNullOrEmpty()

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(
PayPalAnalytics.HANDLE_RETURN_STARTED,
analyticsParams
)
}

approvalUrl?.let {
val pairingId = Uri.parse(approvalUrl).getQueryParameter(tokenKey)
Expand Down Expand Up @@ -195,18 +218,19 @@ class PayPalClient internal constructor(
if (payPalAccountNonce != null) {
callbackTokenizeSuccess(
callback,
PayPalResult.Success(payPalAccountNonce)
PayPalResult.Success(payPalAccountNonce),
isAppSwitchFlow
)
} else if (error != null) {
callbackTokenizeFailure(callback, PayPalResult.Failure(error))
callbackTokenizeFailure(callback, PayPalResult.Failure(error), isAppSwitchFlow)
}
}
} catch (e: UserCanceledException) {
callbackBrowserSwitchCancel(callback, PayPalResult.Cancel)
callbackBrowserSwitchCancel(callback, PayPalResult.Cancel, isAppSwitchFlow)
} catch (e: JSONException) {
callbackTokenizeFailure(callback, PayPalResult.Failure(e))
callbackTokenizeFailure(callback, PayPalResult.Failure(e), isAppSwitchFlow)
} catch (e: PayPalBrowserSwitchException) {
callbackTokenizeFailure(callback, PayPalResult.Failure(e))
callbackTokenizeFailure(callback, PayPalResult.Failure(e), isAppSwitchFlow)
}
}

Expand Down Expand Up @@ -258,32 +282,51 @@ class PayPalClient internal constructor(

private fun callbackBrowserSwitchCancel(
callback: PayPalTokenizeCallback,
cancel: PayPalResult.Cancel
cancel: PayPalResult.Cancel,
isAppSwitchFlow: Boolean
) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.BROWSER_LOGIN_CANCELED, analyticsParams)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_CANCELED, analyticsParams)
}

callback.onPayPalResult(cancel)
}

private fun callbackTokenizeFailure(
callback: PayPalTokenizeCallback,
failure: PayPalResult.Failure
failure: PayPalResult.Failure,
isAppSwitchFlow: Boolean
) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.TOKENIZATION_FAILED, analyticsParams)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_FAILED, analyticsParams)
}

callback.onPayPalResult(failure)
}

private fun callbackTokenizeSuccess(
callback: PayPalTokenizeCallback,
success: PayPalResult.Success
success: PayPalResult.Success,
isAppSwitchFlow: Boolean
) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.TOKENIZATION_SUCCEEDED, analyticsParams)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_SUCCEEDED, analyticsParams)
}

callback.onPayPalResult(success)
}

private val analyticsParams: AnalyticsEventParams
get() {
return AnalyticsEventParams(
payPalContextId = payPalContextId,
linkType = linkType?.stringValue,
isVaultRequest = isVaultRequest
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,12 @@ internal class PayPalInternalClient(
.build()
}

private fun isAppSwitchEnabled(payPalRequest: PayPalRequest): Boolean {
fun isAppSwitchEnabled(payPalRequest: PayPalRequest): Boolean {
return (payPalRequest is PayPalVaultRequest) &&
payPalRequest.enablePayPalAppSwitch
}

private fun isPayPalInstalled(context: Context): Boolean {
fun isPayPalInstalled(context: Context): Boolean {
return deviceInspector.isPayPalInstalled(context)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,54 @@ public void createPaymentAuthRequest_whenCheckoutRequest_sendsPayPalRequestViaIn
any(PayPalInternalClientCallback.class));
}

@Test
public void createPaymentAuthRequest_whenVaultRequest_sendsAppSwitchStartedEvent() {
PayPalVaultRequest payPalVaultRequest = new PayPalVaultRequest(true);
payPalVaultRequest.setUserAuthenticationEmail("[email protected]");
payPalVaultRequest.setEnablePayPalAppSwitch(true);
payPalVaultRequest.setMerchantAccountId("sample-merchant-account-id");

PayPalPaymentAuthRequestParams paymentAuthRequest = new PayPalPaymentAuthRequestParams(
payPalVaultRequest,
null,
"https://example.com/approval/url",
"sample-client-metadata-id",
null,
"https://example.com/success/url"
);
PayPalInternalClient payPalInternalClient =
new MockPayPalInternalClientBuilder().sendRequestSuccess(paymentAuthRequest)
.build();

when(payPalInternalClient.isPayPalInstalled(activity)).thenReturn(true);
when(payPalInternalClient.isAppSwitchEnabled(payPalVaultRequest)).thenReturn(true);

BraintreeClient braintreeClient =
new MockBraintreeClientBuilder().configuration(payPalEnabledConfig).build();

PayPalClient sut = new PayPalClient(braintreeClient, payPalInternalClient);
sut.createPaymentAuthRequest(activity, payPalVaultRequest, paymentAuthCallback);

ArgumentCaptor<PayPalPaymentAuthRequest> captor =
ArgumentCaptor.forClass(PayPalPaymentAuthRequest.class);
verify(paymentAuthCallback).onPayPalPaymentAuthRequest(captor.capture());

PayPalPaymentAuthRequest request = captor.getValue();
assertTrue(request instanceof PayPalPaymentAuthRequest.ReadyToLaunch);
PayPalPaymentAuthRequestParams paymentAuthRequestCaptured =
((PayPalPaymentAuthRequest.ReadyToLaunch) request).getRequestParams();

BrowserSwitchOptions browserSwitchOptions = paymentAuthRequestCaptured.getBrowserSwitchOptions();
assertEquals(BraintreeRequestCodes.PAYPAL.getCode(), browserSwitchOptions.getRequestCode());
assertFalse(browserSwitchOptions.isLaunchAsNewTask());


AnalyticsEventParams params = new AnalyticsEventParams();
params.setVaultRequest(true);
params.setLinkType("universal");
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_STARTED, params);
}

@Test
public void tokenize_withBillingAgreement_tokenizesResponseOnSuccess() throws JSONException {
PayPalInternalClient payPalInternalClient = new MockPayPalInternalClientBuilder().build();
Expand Down Expand Up @@ -451,4 +499,115 @@ public void tokenize_whenPayPalInternalClientTokenizeResult_callsBackResult()
params.setVaultRequest(false);
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.TOKENIZATION_SUCCEEDED, params);
}

@Test
public void tokenize_whenPayPalInternalClientTokenizeResult_sendsAppSwitchSucceededEvents()
throws JSONException {
PayPalAccountNonce payPalAccountNonce = mock(PayPalAccountNonce.class);
PayPalInternalClient payPalInternalClient =
new MockPayPalInternalClientBuilder().tokenizeSuccess(payPalAccountNonce).build();

String approvalUrl =
"sample-scheme://onetouch/v1/success?PayerID=HERMES-SANDBOX-PAYER-ID&paymentId=HERMES-SANDBOX-PAYMENT-ID&token=EC-HERMES-SANDBOX-EC-TOKEN&switch_initiated_time=17166111926211";

BrowserSwitchFinalResult.Success browserSwitchResult = mock(BrowserSwitchFinalResult.Success.class);

when(browserSwitchResult.getRequestMetadata()).thenReturn(
new JSONObject().put("client-metadata-id", "sample-client-metadata-id")
.put("merchant-account-id", "sample-merchant-account-id")
.put("intent", "authorize").put("approval-url", approvalUrl)
.put("success-url", "https://example.com/success")
.put("payment-type", "single-payment"));

Uri uri = Uri.parse(approvalUrl);
when(browserSwitchResult.getReturnUrl()).thenReturn(uri);

PayPalPaymentAuthResult.Success payPalPaymentAuthResult = new PayPalPaymentAuthResult.Success(
browserSwitchResult);
BraintreeClient braintreeClient = new MockBraintreeClientBuilder().build();
PayPalClient sut = new PayPalClient(braintreeClient, payPalInternalClient);

sut.tokenize(payPalPaymentAuthResult, payPalTokenizeCallback);

ArgumentCaptor<PayPalResult> captor = ArgumentCaptor.forClass(PayPalResult.class);
verify(payPalTokenizeCallback).onPayPalResult(captor.capture());

PayPalResult result = captor.getValue();
assertTrue(result instanceof PayPalResult.Success);
assertEquals(payPalAccountNonce, ((PayPalResult.Success) result).getNonce());

AnalyticsEventParams params = new AnalyticsEventParams();
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.HANDLE_RETURN_STARTED, params);
params.setPayPalContextId("EC-HERMES-SANDBOX-EC-TOKEN");
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.TOKENIZATION_SUCCEEDED, params);
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_SUCCEEDED, params);
}

@Test
public void tokenize_whenPayPalNotEnabled_sendsAppSwitchFailedEvents() throws JSONException {
PayPalInternalClient payPalInternalClient = new MockPayPalInternalClientBuilder().build();

String approvalUrl = "https://some-scheme/onetouch/v1/cancel?switch_initiated_time=17166111926211";

BrowserSwitchFinalResult.Success browserSwitchResult = mock(BrowserSwitchFinalResult.Success.class);

when(browserSwitchResult.getRequestMetadata()).thenReturn(
new JSONObject().put("client-metadata-id", "sample-client-metadata-id")
.put("merchant-account-id", "sample-merchant-account-id")
.put("intent", "authorize").put("approval-url", "https://some-scheme/onetouch/v1/cancel?token=SOME-BA&switch_initiated_time=17166111926211")
.put("success-url", "https://example.com/cancel")
.put("payment-type", "single-payment"));

Uri uri = Uri.parse(approvalUrl);
when(browserSwitchResult.getReturnUrl()).thenReturn(uri);

PayPalPaymentAuthResult.Success payPalPaymentAuthResult = new PayPalPaymentAuthResult.Success(
browserSwitchResult);
BraintreeClient braintreeClient = new MockBraintreeClientBuilder().build();
PayPalClient sut = new PayPalClient(braintreeClient, payPalInternalClient);

sut.tokenize(payPalPaymentAuthResult, payPalTokenizeCallback);

AnalyticsEventParams params = new AnalyticsEventParams();
params.setPayPalContextId("SOME-BA");
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.TOKENIZATION_FAILED, params);
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_FAILED, params);
}

@Test
public void tokenize_whenCancelUriReceived_sendsAppSwitchCanceledEvents()
throws JSONException {
PayPalInternalClient payPalInternalClient = new MockPayPalInternalClientBuilder().build();

String approvalUrl = "https://some-scheme/onetouch/v1/cancel?switch_initiated_time=17166111926211";

BrowserSwitchFinalResult.Success browserSwitchResult = mock(BrowserSwitchFinalResult.Success.class);

when(browserSwitchResult.getRequestMetadata()).thenReturn(
new JSONObject().put("client-metadata-id", "sample-client-metadata-id")
.put("merchant-account-id", "sample-merchant-account-id")
.put("intent", "authorize").put("approval-url", approvalUrl)
.put("success-url", "https://example.com/success")
.put("payment-type", "single-payment"));

Uri uri = Uri.parse(approvalUrl);
when(browserSwitchResult.getReturnUrl()).thenReturn(uri);

PayPalPaymentAuthResult.Success payPalPaymentAuthResult = new PayPalPaymentAuthResult.Success(
browserSwitchResult);
BraintreeClient braintreeClient = new MockBraintreeClientBuilder().build();
PayPalClient sut = new PayPalClient(braintreeClient, payPalInternalClient);

sut.tokenize(payPalPaymentAuthResult, payPalTokenizeCallback);

ArgumentCaptor<PayPalResult> captor = ArgumentCaptor.forClass(PayPalResult.class);
verify(payPalTokenizeCallback).onPayPalResult(captor.capture());

PayPalResult result = captor.getValue();
assertTrue(result instanceof PayPalResult.Cancel);

AnalyticsEventParams params = new AnalyticsEventParams();
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.BROWSER_LOGIN_CANCELED, params);
verify(braintreeClient).sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_CANCELED, params);
}
}

0 comments on commit 383917b

Please sign in to comment.