Skip to content

Commit

Permalink
Feature/using checkoutanalytics (#2234)
Browse files Browse the repository at this point in the history
* First use of checkoutanlytics endpoint (for logging onSubmit & createFromAction events)

* Added comments and fixed type

* Commenting out "target" property until API accepts it

* Fixing unit tests

* Fixing sonarcloud gripe

* Tightening up types

* Adding test for event queue

* Updated test description

* Enhancing test

* Adding collect-id unit test

* Adding TelemetryEvent type

* Increasing test coverage

* Adding test

* Adding Storage.test

* Further checks to ensure checkoutanalytics calls fail silently

* Aligning fallback storage solution with normal, window based, Storage API

* Some adjustments now actual endpoint is accepting requests

* Analytics path read from constant and passed to relevant components

* Removing unused components

* Fixed unit test

* Added comments on the shape of objects for the /checkoutanalytics endpoint

* Unit test for when checkoutanalytics url is wrong

* Remove constant for mismatching threeDSServerTransID (it is no longer a situation we catch)

* Use await when calling collectId

* Remove analyticsContext from CoreOptions

* Adding createAnalyticsAction function in Analytics to make it simpler for other components to submit analytics

* Renaming CAEventsQueue to EventsQueue

* Clarifying the "modular" nature of Analytics and EventsQueue

* Fixing return type

* Pass in containerWidth with initial analytics call

* Drop addAnalyticsAction from Analytics module. Tidy up Analytics' types

* Switch to v3 of the endpoint

* Fix unit test (set new analytics version)

* Changes reflecting discussion on final API tweaks

* Create analytics-action for when PM selected or mounted. Send in sessionId in initial call

* Commented out logs and removed unused code

* Moved initial analytics setup to UIElement now that it (potentially) needs to pass on the session.id

* Added type

* Fixed unit test

* Temporarily don't pass isStoredPaymentMethod (waiting for API fix)

* Adding isStoredPaymentMethod to analytics event action now the b/e supports it

* Adding getter for Analytics' enabled prop so 'do-not-track' can be added as checkoutAttemptId value, if required

* fixing e2e tests

* remove .only from e2e test

* Only load analytics pixel once

* comments added

* Aligning Dropin & comps so that a 'mounted' event is always sent, from the same place, for both implementations

* Renamed initial Analytics call from "send" to "setUp"

* Create timer to send events after a set period of time

* Setup analytics after render has been called. Make call to submit the "mounted" event from BaseElement c.f. UIElement

* DropinComponent sends 'rendered' analytics-action

* For clarity UIElement uses switch in submitAnalytics function

* Added comment about Dropin also being able to pass a list of paymentMethods to analytics

* Use single "rendered" event to describe component mounting, dropin pm list rendering & dropin pm selection

* Changing analytics terminology - generic term is events with specific types being: info, log, error

* Changing analytics terminology - info events are collected into an an array named "info" (as opposed to the "logs" & "errors" arrays)

* Fixing unit tests

* Also debounce errors

* Fixed unit test

* Extends feature/using_checkoutanalytics. First draft: adding focus/blur events for Credit card fields

* Second draft: adding error analytics events for Credit card fields

* Second draft: adding error analytics events for Credit card fields

* Second draft: adding error analytics events for Credit card fields

* Using UIElement.submitAnalytics as a gateway for all analytics events (to set event type and create final analytics worthy objects)

* Added comment about onFocus/onBlur callbacks now working for non-SFs

* Changed constant ANALYTICS_UNFOCUS_STR to have value "unfocus"

* Non dropdown fields for Address also have focus/blur analytics

* Reduce the analytics info event timer to 5 secs when in development mode

* Aligning some analytics values with what the endpoint expects

* Fixing typo

* Clauses added so unit tests pass

* Adding analytics for when an instant PM button is pressed

* Fixed TS error

* Removing unused code

* Keep object sent to onFocus & onBlur callbacks in the form expected by v5 users

* Moving logic to create different types of Analytics events into the Analytics module

* Improving types

* Redeclare BaseElement.this._node *before* we render (was causing an issue in CustomCard)

* Checkoutanalytics mvp with 3DS2 events (#2531)

* Add 3DS2 analytics events for data sent & iframe loaded. Plus error event when paymentData missing in 3DS2

* Using UIElement.submitAnalytics for 3DS2

* Fixed clause (had been changed to test error)

* Move app specific analytics logic to a separate "processing" function. This separates concerns and makes testing easier

* core.ts uses sendAnalytics function

* Comments added regarding what properties are added to what analytics objects

* Redeclare BaseElement.this._node *before* we render (was causing an issue in CustomCard)

* Card has onConfigSuccess function to send analytics info event (type="configured") when SFS have all configured

* Adding analytics logs for when 3DS2 fingerprint or challenge complete

* Also pass isStoredPaymentMethod and brand with "configured" analytics events for storedCards

* Added unit tests for Card & GooglePay testing the shape of the analytics objects they generate

* Added unit tests for 3DS2 errors - testing the shape of the analytics objects they generate

* Fixing type

* Jumping through hoops for sonarcloud

* Jumping through hoops for sonarcloud

* Removing console.log

* Specifying type. Move card related, "is it a stored card" logic, out of UIElement

* Fixing linting

* Fixing linting, tightening up onSubmitAnalytics type

* After SDKs meeting: removing target prop from "submit" log events

* Added "focus" & "unfocus" info events to Custom Card comp

* Feature/using checkoutanalytics mvp iDeal (#2549)

* Adding selected info event for issuerList buttons

* IssuerList handles analytics for both dropdown & issuerList buttons

* Detect and send analytics event when issuerList's dropdown is expanded

* Detect and send analytics event (debounced) when issuerList search functionality is used

* Moved debounce function to own util file

* Fixes for unit tests

* Made debounce for search a constant

* Adding new unit tests

* Removing child comp's submitAnalytics function (since all it does is call super). Removing unused param.

* Feature/using checkoutanalytics mvp qr codes (#2550)

* Detect and send analytics event when qrCode's "Copy" btn is pressed

* Fixing unit test fail

* Removing child comp's submitAnalytics function (since all it does is call super)

* Add platform prop to all subsequent analytics calls

* All events have a unique id

* Fixing type

* Fixing unit tests

* Fixing issue where analytics calls were still being sent despite checkoutAttemptId call failing

* Added some tsconfig & linting exclusions

* Failed attempt to retrieve checkoutAttemptId returns rejected promise

* Refactoring Analytics.test to run in a way that more accurately mimics how analytics events are created

* Feature/using checkoutanalytics mapping validation errors (#2583)

* Mapping validation error codes to those expected by the endpoint

* Fixing unit tests

* Changing logic that dictates when we jump focus from PAN to expiryDate

* Feature/using checkoutanalytics_with_base64 refactor (#2586)

* Extending branch feature_using_checkoutanalytics to add 3DS2 analytic-actions (for data sent & iframe loaded)

* commenting out console logs

* Extending branch feature_using_checkoutanalytics_with_3DS2_events to add base64 refactor code (more errors detected in decode process)

* Changing analytics terminology - generic term is events with specific types being: info, log, error

* Changing analytics terminology - generic term is events with specific types being: info, log, error

* Fixing unit tests

* Fixing unit tests

* Fixed unit test

* Adding util to determine if base64 decoding has lead to an error object. Tightened up types

* Changing message for actionHandled when challenge iframe loaded

* Updating card rules for Discover and Diners, just for transparency on what the rules are within securedFields

* Fixing e2e tests
  • Loading branch information
sponglord authored Mar 11, 2024
1 parent 73eb69f commit e440a5c
Show file tree
Hide file tree
Showing 93 changed files with 2,083 additions and 442 deletions.
5 changes: 5 additions & 0 deletions .changeset/selfish-socks-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': minor
---

Starting using /checkoutanalytics endpoint to retrieve "checkoutAttemptId" log "submit" and "action-handled" events
4 changes: 1 addition & 3 deletions packages/e2e/tests/vouchers/boleto/boleto.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ const mockData = {
houseNumberOrName: '123',
city: 'Sao Paulo',
postalCode: '11111555',
stateOrProvince: 'SP',
firstName: 'N/A',
lastName: 'N/A'
stateOrProvince: 'SP'
},
shopperEmail: '[email protected]'
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import classNames from 'classnames';
import styles from '../AchInput.module.scss';
import Field from '../../../../internal/FormFields/Field';
import DataSfSpan from '../../../../Card/components/CardInput/components/DataSfSpan';
import { capitalizeFirstLetter } from '../../../../../utils/Formatters/formatters';

const AchSFInput = ({ id, dataInfo, className = '', label, focused, filled, errorMessage = '', isValid = false, onFocusField, dir }) => {
const capitalisedId = id.charAt(0).toUpperCase() + id.slice(1);
const capitalisedId = capitalizeFirstLetter(id);
const encryptedIdStr = `encrypted${capitalisedId}`;

return (
Expand Down
18 changes: 14 additions & 4 deletions packages/lib/src/components/ApplePay/ApplePay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { preparePaymentRequest } from './payment-request';
import { resolveSupportedVersion, mapBrands } from './utils';
import { ApplePayElementProps, ApplePayElementData, ApplePaySessionRequest, OnAuthorizedCallback } from './types';
import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError';
import { ANALYTICS_INSTANT_PAYMENT_BUTTON, ANALYTICS_SELECTED_STR } from '../../core/Analytics/constants';
import { DecodeObject } from '../types';

const latestSupportedVersion = 14;

Expand Down Expand Up @@ -53,6 +55,11 @@ class ApplePayElement extends UIElement<ApplePayElementProps> {
}

submit() {
// Analytics
if (this.props.isInstantPayment) {
this.submitAnalytics({ type: ANALYTICS_SELECTED_STR, target: ANALYTICS_INSTANT_PAYMENT_BUTTON });
}

return this.startSession(this.props.onAuthorized);
}

Expand Down Expand Up @@ -104,10 +111,13 @@ class ApplePayElement extends UIElement<ApplePayElementProps> {

try {
const response = await httpPost(options, request);
const decodedData = base64.decode(response.data);
if (!decodedData) reject('Could not decode Apple Pay session');
const session = JSON.parse(decodedData as string);
resolve(session);
const decodedData: DecodeObject = base64.decode(response.data);
if (!decodedData.success) {
reject('Could not decode Apple Pay session');
} else {
const session = JSON.parse(decodedData.data);
resolve(session);
}
} catch (e) {
reject('Could not get Apple Pay session');
}
Expand Down
46 changes: 35 additions & 11 deletions packages/lib/src/components/BaseElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import Core from '../core';
import { BaseElementProps, PaymentData } from './types';
import { RiskData } from '../core/RiskModule/RiskModule';
import { Resources } from '../core/Context/Resources';
import { AnalyticsInitialEvent, SendAnalyticsObject } from '../core/Analytics/types';
import { ANALYTICS_RENDERED_STR } from '../core/Analytics/constants';

class BaseElement<P extends BaseElementProps> {
public readonly _id = `${this.constructor['type']}-${uuid()}`;
Expand Down Expand Up @@ -46,6 +48,16 @@ class BaseElement<P extends BaseElementProps> {
return {};
}

/* eslint-disable-next-line */
protected setUpAnalytics(setUpAnalyticsObj: AnalyticsInitialEvent) {
return null;
}

/* eslint-disable-next-line */
protected submitAnalytics(analyticsObj?: SendAnalyticsObject) {
return null;
}

protected setState(newState: object): void {
this.state = { ...this.state, ...newState };
}
Expand All @@ -56,8 +68,8 @@ class BaseElement<P extends BaseElementProps> {
*/
get data(): PaymentData | RiskData {
const clientData = getProp(this.props, 'modules.risk.data');
const useAnalytics = !!getProp(this.props, 'modules.analytics.props.enabled');
const checkoutAttemptId = useAnalytics ? getProp(this.props, 'modules.analytics.checkoutAttemptId') : 'do-not-track';
const useAnalytics = !!getProp(this.props, 'modules.analytics.getEnabled')?.();
const checkoutAttemptId = useAnalytics ? getProp(this.props, 'modules.analytics.getCheckoutAttemptId')?.() : 'do-not-track';
const order = this.state.order || this.props.order;

const componentData = this.formatData();
Expand Down Expand Up @@ -98,24 +110,36 @@ class BaseElement<P extends BaseElementProps> {
throw new Error('Component could not mount. Root node was not found.');
}

const setupAnalytics = !this._node;

if (this._node) {
this.unmount(); // new, if this._node exists then we are "remounting" so we first need to unmount if it's not already been done
} else {
// Set up analytics, once
if (this.props.modules && this.props.modules.analytics && !this.props.isDropin) {
this.props.modules.analytics.send({
containerWidth: this._node && this._node.offsetWidth,
component: this.constructor['analyticsType'] ?? this.constructor['type'],
flavor: 'components'
});
}
}

this._node = node;

this._component = this.render();

render(this._component, node);

// Set up analytics (once, since this._node is currently undefined) now that we have mounted and rendered
if (setupAnalytics) {
if (this.props.modules && this.props.modules.analytics) {
this.setUpAnalytics({
containerWidth: node && (node as HTMLElement).offsetWidth,
component: !this.props.isDropin ? this.constructor['analyticsType'] ?? this.constructor['type'] : 'dropin',
flavor: !this.props.isDropin ? 'components' : 'dropin'
}).then(() => {
// Once the initial analytics set up call has been made...
// ...create an analytics event declaring that the component has been rendered
// (The dropin will do this itself from DropinComponent once the PM list has rendered)
if (!this.props.isDropin) {
this.submitAnalytics({ type: ANALYTICS_RENDERED_STR });
}
});
}
}

return this;
}

Expand Down
162 changes: 162 additions & 0 deletions packages/lib/src/components/Card/Card.Analytics.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { CardElement } from './Card';
import Analytics from '../../core/Analytics';

const analyticsModule = Analytics({ analytics: {}, loadingContext: '', locale: '', clientKey: '' });

let card;

import {
ANALYTICS_CONFIGURED_STR,
ANALYTICS_EVENT_INFO,
ANALYTICS_EVENT_LOG,
ANALYTICS_FOCUS_STR,
ANALYTICS_RENDERED_STR,
ANALYTICS_SUBMIT_STR,
ANALYTICS_UNFOCUS_STR,
ANALYTICS_VALIDATION_ERROR_STR
} from '../../core/Analytics/constants';

describe('Card: calls that generate "info" analytics should produce objects with the expected shapes ', () => {
beforeEach(() => {
console.log = jest.fn(() => {});

card = new CardElement({
modules: {
analytics: analyticsModule
}
});

analyticsModule.createAnalyticsEvent = jest.fn(() => null);
});

test('Analytics should produce an "info" event, of type "rendered", for a card PM', () => {
card.submitAnalytics({
type: ANALYTICS_RENDERED_STR
});

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_INFO,
data: { component: card.constructor['type'], type: ANALYTICS_RENDERED_STR }
});
});

test('Analytics should produce an "info" event, of type "rendered", for a storedCard PM', () => {
card.submitAnalytics({
type: ANALYTICS_RENDERED_STR,
isStoredPaymentMethod: true,
brand: 'mc'
});

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_INFO,
data: { component: card.constructor['type'], type: ANALYTICS_RENDERED_STR, isStoredPaymentMethod: true, brand: 'mc' }
});
});

test('Analytics should produce an "info" event, of type "configured", for a card PM', () => {
card.submitAnalytics({
type: ANALYTICS_CONFIGURED_STR
});

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_INFO,
data: { component: card.constructor['type'], type: ANALYTICS_CONFIGURED_STR }
});
});

test('Analytics should produce an "info" event, of type "configured", for a storedCard PM', () => {
card.submitAnalytics({
type: ANALYTICS_CONFIGURED_STR,
isStoredPaymentMethod: true,
brand: 'mc'
});

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_INFO,
data: { component: card.constructor['type'], type: ANALYTICS_CONFIGURED_STR, isStoredPaymentMethod: true, brand: 'mc' }
});
});

test('Analytics should produce an "info" event, of type "focus" with the target value correctly formed', () => {
card.onFocus({
fieldType: 'encryptedCardNumber',
event: {
action: 'focus',
focus: true,
numChars: 0,
fieldType: 'encryptedCardNumber',
rootNode: {},
type: 'card',
currentFocusObject: 'encryptedCardNumber'
}
});

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_INFO,
data: { component: card.constructor['type'], type: ANALYTICS_FOCUS_STR, target: 'card_number' }
});
});

test('Analytics should produce an "info" event, of type "unfocus" with the target value correctly formed', () => {
card.onBlur({
fieldType: 'encryptedCardNumber',
event: {
action: 'focus',
focus: false,
numChars: 1,
fieldType: 'encryptedCardNumber',
rootNode: {},
type: 'card',
currentFocusObject: null
}
});

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_INFO,
data: { component: card.constructor['type'], type: ANALYTICS_UNFOCUS_STR, target: 'card_number' }
});
});

test('Analytics should produce an "info" event, of type "validationError", with the expected properties', () => {
card.onErrorAnalytics({
fieldType: 'encryptedCardNumber',
errorCode: 'error.va.sf-cc-num.04'
});

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_INFO,
data: {
component: card.constructor['type'],
type: ANALYTICS_VALIDATION_ERROR_STR,
target: 'card_number',
validationErrorCode: 'error.va.sf-cc-num.04',
validationErrorMessage: 'card-number-not-filled-correctly'
}
});
});
});

describe('Card: calls that generate "log" analytics should produce objects with the expected shapes ', () => {
beforeEach(() => {
console.log = jest.fn(() => {});

card = new CardElement({
modules: {
analytics: analyticsModule
}
});
});

test('Analytics should produce an "log" event, of type "submit", for a card PM', () => {
card.submitAnalytics({ type: ANALYTICS_SUBMIT_STR });

expect(analyticsModule.createAnalyticsEvent).toHaveBeenCalledWith({
event: ANALYTICS_EVENT_LOG,
data: {
component: card.constructor['type'],
type: ANALYTICS_SUBMIT_STR,
message: 'Shopper clicked pay'
}
});
});
});
Loading

0 comments on commit e440a5c

Please sign in to comment.