diff --git a/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-initialize-options.ts b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-initialize-options.ts new file mode 100644 index 0000000000..98e145c71f --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-initialize-options.ts @@ -0,0 +1,81 @@ +import { + BraintreeError, + BraintreeFormOptions, + BraintreeThreeDSecureOptions, +} from '@bigcommerce/checkout-sdk/braintree-utils'; +import { StandardError } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +export interface BraintreeCreditCardPaymentInitializeOptions { + /** + * A list of card brands that are not supported by the merchant. + * + * List of supported brands by braintree can be found here: https://braintree.github.io/braintree-web/current/module-braintree-web_hosted-fields.html#~field + * search for `supportedCardBrands` property. + * + * List of credit cards brands: + * 'visa', + * 'mastercard', + * 'american-express', + * 'diners-club', + * 'discover', + * 'jcb', + * 'union-pay', + * 'maestro', + * 'elo', + * 'mir', + * 'hiper', + * 'hipercard' + * + * */ + unsupportedCardBrands?: string[]; + /** + * The CSS selector of a container where the payment widget should be inserted into. + */ + containerId?: string; + + threeDSecure?: BraintreeThreeDSecureOptions; + + /** + * @alpha + * Please note that this option is currently in an early stage of + * development. Therefore the API is unstable and not ready for public + * consumption. + */ + form?: BraintreeFormOptions; + + /** + * The location to insert the Pay Later Messages. + */ + bannerContainerId?: string; + + /** + * A callback right before render Smart Payment Button that gets called when + * Smart Payment Button is eligible. This callback can be used to hide the standard submit button. + */ + onRenderButton?(): void; + + /** + * A callback for submitting payment form that gets called + * when buyer approved PayPal account. + */ + submitForm?(): void; + + /** + * A callback that gets called if unable to submit payment. + * + * @param error - The error object describing the failure. + */ + onPaymentError?(error: BraintreeError | StandardError): void; + + /** + * A callback for displaying error popup. This callback requires error object as parameter. + */ + onError?(error: unknown): void; +} + +export interface WithBraintreeCreditCardPaymentInitializeOptions { + /** + * The options that are required to initialize Braintree PayPal wallet button on Product and Cart page. + */ + braintree?: BraintreeCreditCardPaymentInitializeOptions; +} diff --git a/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.spec.ts b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.spec.ts new file mode 100644 index 0000000000..f0046dfd07 --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.spec.ts @@ -0,0 +1,515 @@ +import BraintreeCreditCardPaymentStrategy from './braintree-credit-card-payment-strategy'; +import { + MissingDataError, + OrderFinalizationNotRequiredError, + OrderRequestBody, + PaymentInitializeOptions, + PaymentIntegrationService, + PaymentMethod, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { + getCart, + getConfig, + getOrderRequestBody, + PaymentIntegrationServiceMock, +} from '@bigcommerce/checkout-sdk/payment-integrations-test-utils'; +import { + BraintreeFastlane, + BraintreeIntegrationService, + BraintreeScriptLoader, + BraintreeSDKVersionManager, + getBraintree, + getFastlaneMock, +} from '@bigcommerce/checkout-sdk/braintree-utils'; +import { getScriptLoader } from '@bigcommerce/script-loader'; +import { + getBillingAddress, + getThreeDSecureMock, + getThreeDSecureOptionsMock, + getTokenizeResponseBody, +} from '../mocks/braintree.mock'; +import { merge } from 'lodash'; +import BraintreeHostedForm from '../braintree-hosted-form/braintree-hosted-form'; +import { + BraintreeCreditCardPaymentInitializeOptions, + WithBraintreeCreditCardPaymentInitializeOptions, +} from './braintree-credit-card-payment-initialize-options'; + +describe('BraintreeCreditCardPaymentStrategy', () => { + let braintreeCreditCardPaymentStrategy: BraintreeCreditCardPaymentStrategy; + let paymentIntegrationService: PaymentIntegrationService; + let braintreeIntegrationService: BraintreeIntegrationService; + let braintreeScriptLoader: BraintreeScriptLoader; + let braintreeHostedForm: BraintreeHostedForm; + let paymentMethod: PaymentMethod; + let braintreeFastlaneMock: BraintreeFastlane; + let braintreeSDKVersionManager: BraintreeSDKVersionManager; + + beforeEach(() => { + const methodId = 'braintree'; + paymentMethod = { + ...getBraintree(), + id: methodId, + initializationData: { + isAcceleratedCheckoutEnabled: true, + shouldRunAcceleratedCheckout: true, + }, + }; + + paymentIntegrationService = new PaymentIntegrationServiceMock(); + + braintreeSDKVersionManager = new BraintreeSDKVersionManager(paymentIntegrationService); + braintreeScriptLoader = new BraintreeScriptLoader( + getScriptLoader(), + window, + braintreeSDKVersionManager, + ); + braintreeFastlaneMock = getFastlaneMock(); + braintreeIntegrationService = new BraintreeIntegrationService( + braintreeScriptLoader, + window, + ); + braintreeHostedForm = new BraintreeHostedForm(braintreeScriptLoader); + braintreeCreditCardPaymentStrategy = new BraintreeCreditCardPaymentStrategy( + paymentIntegrationService, + braintreeIntegrationService, + braintreeHostedForm, + ); + + jest.spyOn(paymentIntegrationService.getState(), 'getPaymentMethodOrThrow').mockReturnValue( + paymentMethod, + ); + jest.spyOn(braintreeIntegrationService, 'getSessionId').mockResolvedValue('sessionId'); + jest.spyOn(braintreeIntegrationService, 'getBraintreeFastlane').mockResolvedValue( + braintreeFastlaneMock, + ); + jest.spyOn(braintreeIntegrationService, 'initialize'); + braintreeScriptLoader.loadClient = jest.fn(); + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn(), + }); + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn(), + }); + jest.spyOn(braintreeHostedForm, 'initialize'); + jest.spyOn(paymentIntegrationService.getState(), 'getPaymentMethodOrThrow').mockReturnValue( + paymentMethod, + ); + jest.spyOn(braintreeIntegrationService, 'teardown'); + jest.spyOn(braintreeHostedForm, 'deinitialize'); + jest.spyOn(paymentIntegrationService, 'submitPayment'); + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn(), + }); + jest.spyOn(braintreeScriptLoader, 'loadClient').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + request: jest.fn(), + }), + }); + jest.spyOn(braintreeIntegrationService, 'tokenizeCard'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates an instance of the braintree payment strategy', () => { + expect(braintreeCreditCardPaymentStrategy).toBeInstanceOf( + BraintreeCreditCardPaymentStrategy, + ); + }); + + describe('#initialize()', () => { + it('throws error if client token is missing', async () => { + paymentMethod.clientToken = ''; + + try { + await braintreeCreditCardPaymentStrategy.initialize({ + methodId: paymentMethod.id, + }); + } catch (error) { + expect(error).toBeInstanceOf(MissingDataError); + } + }); + + it('initializes the strategy', async () => { + paymentMethod.config.isHostedFormEnabled = false; + + const options: PaymentInitializeOptions & + WithBraintreeCreditCardPaymentInitializeOptions = { + braintree: { + form: { + fields: { + cardNumberVerification: { + instrumentId: 'instrument123', + containerId: 'containerId', + }, + }, + }, + unsupportedCardBrands: [], + }, + methodId: paymentMethod.id, + }; + + await braintreeCreditCardPaymentStrategy.initialize(options); + + expect(braintreeIntegrationService.initialize).toHaveBeenCalledWith( + paymentMethod.clientToken, + ); + expect(braintreeIntegrationService.getSessionId).toHaveBeenCalled(); + jest.spyOn(braintreeHostedForm, 'initialize'); + }); + }); + + it('initializes the strategy as hosted form if feature is enabled and configuration is passed', async () => { + paymentMethod.config.isHostedFormEnabled = true; + jest.spyOn(braintreeHostedForm, 'isInitialized').mockReturnValue(true); + + const options = { + methodId: paymentMethod.id, + braintree: { + form: { + fields: { + cardName: { containerId: 'cardName' }, + cardNumber: { containerId: 'cardNumber' }, + cardExpiry: { containerId: 'cardExpiry' }, + }, + }, + unsupportedCardBrands: ['american-express', 'diners-club'], + }, + }; + + await braintreeCreditCardPaymentStrategy.initialize(options); + + expect(braintreeIntegrationService.initialize).toHaveBeenCalledWith( + paymentMethod.clientToken, + ); + expect(braintreeHostedForm.initialize).toHaveBeenCalledWith( + options.braintree.form, + options.braintree.unsupportedCardBrands, + 'clientToken', + ); + expect(braintreeIntegrationService.getSessionId).toHaveBeenCalled(); + }); + + it('initializes braintree fastlane sdk', async () => { + const cart = getCart(); + const storeConfig = getConfig().storeConfig; + + jest.spyOn(paymentIntegrationService.getState(), 'getCartOrThrow').mockReturnValue(cart); + jest.spyOn(paymentIntegrationService.getState(), 'getStoreConfigOrThrow').mockReturnValue( + storeConfig, + ); + jest.spyOn( + paymentIntegrationService.getState(), + 'getPaymentProviderCustomer', + ).mockReturnValue(undefined); + + paymentMethod.initializationData.isAcceleratedCheckoutEnabled = true; + + const options: PaymentInitializeOptions & WithBraintreeCreditCardPaymentInitializeOptions = + { + methodId: paymentMethod.id, + braintree: { + form: { + fields: { + cardName: { containerId: 'cardName' }, + cardNumber: { containerId: 'cardNumber' }, + cardExpiry: { containerId: 'cardExpiry' }, + }, + }, + unsupportedCardBrands: [], + }, + }; + + await braintreeCreditCardPaymentStrategy.initialize(options); + + expect(braintreeIntegrationService.initialize).toHaveBeenCalled(); + expect(braintreeIntegrationService.getBraintreeFastlane).toHaveBeenCalled(); + }); + + describe('#deinitialize()', () => { + it('deinitializes strategy', async () => { + await braintreeCreditCardPaymentStrategy.deinitialize(); + + expect(braintreeIntegrationService.teardown).toHaveBeenCalled(); + expect(braintreeHostedForm.deinitialize).toHaveBeenCalled(); + }); + + it('resets hosted form initialization state on strategy deinitialization', async () => { + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + }); + jest.spyOn(braintreeHostedForm, 'tokenize'); + braintreeHostedForm.deinitialize = jest.fn(() => Promise.resolve()); + paymentMethod.config.isHostedFormEnabled = true; + + await braintreeCreditCardPaymentStrategy.initialize({ + methodId: paymentMethod.id, + braintree: { + form: { + fields: { + cardName: { containerId: 'cardName' }, + cardNumber: { containerId: 'cardNumber' }, + cardExpiry: { containerId: 'cardExpiry' }, + }, + }, + unsupportedCardBrands: [], + }, + }); + + await braintreeCreditCardPaymentStrategy.deinitialize(); + await braintreeCreditCardPaymentStrategy.execute(getOrderRequestBody()); + + expect(braintreeHostedForm.tokenize).not.toHaveBeenCalled(); + expect(braintreeIntegrationService.teardown).toHaveBeenCalled(); + }); + }); + + describe('#finalize()', () => { + it('throws error to inform that order finalization is not required', async () => { + try { + await braintreeCreditCardPaymentStrategy.finalize(); + } catch (error) { + expect(error).toBeInstanceOf(OrderFinalizationNotRequiredError); + } + }); + }); + + describe('#execute()', () => { + let orderRequestBody: OrderRequestBody; + + beforeEach(() => { + orderRequestBody = getOrderRequestBody(); + }); + + describe('common execution behaviour', () => { + it('calls submit order with the order request information', async () => { + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + }); + + await braintreeCreditCardPaymentStrategy.execute(getOrderRequestBody()); + + expect(paymentIntegrationService.submitOrder).toHaveBeenCalled(); + }); + + describe('non hosted form behaviour', () => { + it('passes on optional flags to save and to make default', async () => { + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + }); + + const payload = merge({}, getOrderRequestBody(), { + payment: { + paymentData: { + shouldSaveInstrument: true, + shouldSetAsDefaultInstrument: true, + }, + }, + }); + + await braintreeCreditCardPaymentStrategy.execute(payload); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith( + expect.objectContaining({ + paymentData: expect.objectContaining({ + shouldSaveInstrument: true, + shouldSetAsDefaultInstrument: true, + }), + }), + ); + }); + + it('does nothing to VaultedInstruments', async () => { + const payload = { + ...getOrderRequestBody(), + payment: { + methodId: 'braintree', + paymentData: { + instrumentId: 'my_instrument_id', + iin: '123123', + }, + }, + }; + + await braintreeCreditCardPaymentStrategy.execute(payload); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith( + payload.payment, + ); + }); + + it('tokenizes the card', async () => { + jest.spyOn(paymentIntegrationService, 'submitPayment'); + jest.spyOn(braintreeHostedForm, 'tokenize'); + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + }); + const expected = { + ...getOrderRequestBody().payment, + paymentData: { + deviceSessionId: 'sessionId', + nonce: 'demo_nonce', + shouldSaveInstrument: false, + shouldSetAsDefaultInstrument: false, + }, + }; + + await braintreeCreditCardPaymentStrategy.initialize({ + methodId: paymentMethod.id, + }); + await braintreeCreditCardPaymentStrategy.execute(getOrderRequestBody()); + + expect(braintreeIntegrationService.tokenizeCard).toHaveBeenCalledWith( + getOrderRequestBody().payment, + getBillingAddress(), + ); + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith(expected); + }); + + it('verifies the card if 3ds is enabled', async () => { + jest.spyOn(braintreeIntegrationService, 'get3DS').mockResolvedValue({ + ...getThreeDSecureMock(), + }); + jest.spyOn(braintreeIntegrationService, 'verifyCard').mockResolvedValue({ + nonce: 'demo_nonce', + }); + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + }); + const options3ds = { + methodId: paymentMethod.id, + braintree: { + threeDSecure: getThreeDSecureOptionsMock(), + form: { + fields: {}, + cardCodeVerification: { + instrumentId: 'my_instrument_id', + containerId: 'my_container_id', + }, + }, + unsupportedCardBrands: [], + }, + }; + + paymentMethod.config.is3dsEnabled = true; + + await braintreeCreditCardPaymentStrategy.initialize(options3ds); + + const expected = { + ...getOrderRequestBody().payment, + paymentData: { + deviceSessionId: 'sessionId', + nonce: 'demo_nonce', + shouldSaveInstrument: false, + shouldSetAsDefaultInstrument: false, + }, + }; + + await braintreeCreditCardPaymentStrategy.execute(getOrderRequestBody()); + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith(expected); + }); + }); + }); + + describe('hosted form behaviour', () => { + let initializeOptions: BraintreeCreditCardPaymentInitializeOptions; + + beforeEach(() => { + jest.spyOn(braintreeHostedForm, 'tokenizeForStoredCardVerification'); + jest.spyOn(braintreeHostedForm, 'tokenize'); + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockReturnValue({ + on: jest.fn(), + getState: jest.fn().mockReturnValue({ + fields: {}, + }), + tokenize: jest.fn().mockResolvedValue({ + nonce: 'my_tokenized_card_with_hosted_form', + }), + }), + }); + + initializeOptions = { + form: { + fields: { + cardName: { containerId: 'cardName' }, + cardNumber: { containerId: 'cardNumber' }, + cardExpiry: { containerId: 'cardExpiry' }, + }, + }, + unsupportedCardBrands: [], + }; + + paymentMethod.config.isHostedFormEnabled = true; + }); + + it('tokenizes payment data through hosted form and submits it', async () => { + await braintreeCreditCardPaymentStrategy.initialize({ + methodId: paymentMethod.id, + braintree: initializeOptions, + }); + + await braintreeCreditCardPaymentStrategy.execute(orderRequestBody); + + expect(braintreeHostedForm.tokenize).toHaveBeenCalledWith(getBillingAddress()); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith({ + ...orderRequestBody.payment, + paymentData: { + deviceSessionId: 'sessionId', + nonce: 'my_tokenized_card_with_hosted_form', + shouldSaveInstrument: false, + shouldSetAsDefaultInstrument: false, + }, + }); + }); + + it('passes save instrument flags if set', async () => { + const payload = merge({}, orderRequestBody, { + payment: { + paymentData: { + shouldSaveInstrument: true, + shouldSetAsDefaultInstrument: true, + }, + }, + }); + + await braintreeCreditCardPaymentStrategy.initialize({ + methodId: paymentMethod.id, + braintree: initializeOptions, + }); + + await braintreeCreditCardPaymentStrategy.execute(payload); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith( + expect.objectContaining({ + paymentData: expect.objectContaining({ + shouldSaveInstrument: true, + shouldSetAsDefaultInstrument: true, + }), + }), + ); + }); + + it('does nothing to VaultedInstruments', async () => { + const payload = { + ...orderRequestBody, + payment: { + methodId: paymentMethod.id, + paymentData: { + instrumentId: 'my_instrument_id', + }, + }, + }; + + await braintreeCreditCardPaymentStrategy.execute(payload); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith( + payload.payment, + ); + }); + }); + }); +}); diff --git a/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.ts b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.ts new file mode 100644 index 0000000000..5f254fa76f --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.ts @@ -0,0 +1,307 @@ +import { some } from 'lodash'; + +import { + BraintreeIntegrationService, + isBraintreeAcceleratedCheckoutCustomer, + isBraintreePaymentRequest3DSError, +} from '@bigcommerce/checkout-sdk/braintree-utils'; + +import { + Address, + BraintreePaymentInstrument, + isHostedInstrumentLike, + isVaultedInstrument, + MissingDataError, + MissingDataErrorType, + NonceInstrument, + OrderFinalizationNotRequiredError, + OrderPaymentRequestBody, + OrderRequestBody, + PaymentArgumentInvalidError, + PaymentInitializeOptions, + PaymentInstrumentMeta, + PaymentIntegrationService, + PaymentMethod, + PaymentMethodFailedError, + PaymentStrategy, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import BraintreeHostedForm from '../braintree-hosted-form/braintree-hosted-form'; +import { WithBraintreeCreditCardPaymentInitializeOptions } from './braintree-credit-card-payment-initialize-options'; + +export default class BraintreeCreditCardPaymentStrategy implements PaymentStrategy { + private is3dsEnabled?: boolean; + private isHostedFormInitialized?: boolean; + private deviceSessionId?: string; + private paymentMethod?: PaymentMethod; + + constructor( + private paymentIntegrationService: PaymentIntegrationService, + private braintreeIntegrationService: BraintreeIntegrationService, + private braintreeHostedForm: BraintreeHostedForm, + ) {} + + async initialize( + options: PaymentInitializeOptions & WithBraintreeCreditCardPaymentInitializeOptions, + ): Promise { + console.log('PACKAGES'); + const { methodId, gatewayId, braintree } = options; + await this.paymentIntegrationService.loadPaymentMethod(methodId); + const state = this.paymentIntegrationService.getState(); + + this.paymentMethod = state.getPaymentMethodOrThrow(methodId); + const { clientToken } = this.paymentMethod; + + if (!clientToken) { + throw new MissingDataError(MissingDataErrorType.MissingPaymentMethod); + } + + try { + this.braintreeIntegrationService.initialize(clientToken, braintree?.threeDSecure); + + if (this.isHostedPaymentFormEnabled(methodId, gatewayId) && braintree?.form) { + await this.braintreeHostedForm.initialize( + braintree.form, + braintree.unsupportedCardBrands, + clientToken, + ); + + this.isHostedFormInitialized = this.braintreeHostedForm.isInitialized(); + } + + this.is3dsEnabled = this.paymentMethod.config.is3dsEnabled; + this.deviceSessionId = await this.braintreeIntegrationService.getSessionId(); + + // TODO: Remove when BT AXO A/B testing is finished + if (this.shouldInitializeBraintreeFastlane()) { + await this.initializeBraintreeFastlaneOrThrow(methodId); + } + } catch (error) { + return this.handleError(error); + } + } + + async execute(orderRequest: OrderRequestBody): Promise { + const { payment, ...order } = orderRequest; + + if (!payment) { + throw new PaymentArgumentInvalidError(['payment']); + } + + if (this.isHostedFormInitialized) { + this.braintreeHostedForm.validate(); + } + + await this.paymentIntegrationService.submitOrder(order); + const state = this.paymentIntegrationService.getState(); + const billingAddress = state.getBillingAddressOrThrow(); + const orderAmount = state.getOrderOrThrow().orderAmount; + + try { + const paymentData = this.isHostedFormInitialized + ? await this.prepareHostedPaymentData(payment, billingAddress, orderAmount) + : await this.preparePaymentData(payment, billingAddress, orderAmount); + + await this.paymentIntegrationService.submitPayment({ + ...payment, + paymentData, + }); + } catch (error) { + return this.processAdditionalAction(error, payment, orderAmount); + } + } + + finalize(): Promise { + return Promise.reject(new OrderFinalizationNotRequiredError()); + } + + async deinitialize(): Promise { + this.isHostedFormInitialized = false; + + await Promise.all([ + this.braintreeIntegrationService.teardown(), + this.braintreeHostedForm.deinitialize(), + ]); + + return Promise.resolve(); + } + + private handleError(error: unknown): never { + if (error instanceof Error && error.name === 'BraintreeError') { + throw new PaymentMethodFailedError(error.message); + } + + throw error; + } + + private async preparePaymentData( + payment: OrderPaymentRequestBody, + billingAddress: Address, + orderAmount: number, + ): Promise { + const { paymentData } = payment; + const commonPaymentData = { deviceSessionId: this.deviceSessionId }; + + if (this.isSubmittingWithStoredCard(payment)) { + return { + ...commonPaymentData, + ...paymentData, + }; + } + + const { shouldSaveInstrument = false, shouldSetAsDefaultInstrument = false } = + isHostedInstrumentLike(paymentData) ? paymentData : {}; + + const { nonce } = this.shouldPerform3DSVerification(payment) + ? await this.braintreeIntegrationService.verifyCard( + payment, + billingAddress, + orderAmount, + ) + : await this.braintreeIntegrationService.tokenizeCard(payment, billingAddress); + + return { + ...commonPaymentData, + nonce, + shouldSaveInstrument, + shouldSetAsDefaultInstrument, + }; + } + + private async prepareHostedPaymentData( + payment: OrderPaymentRequestBody, + billingAddress: Address, + orderAmount: number, + ): Promise { + const { paymentData } = payment; + const commonPaymentData = { deviceSessionId: this.deviceSessionId }; + + if (this.isSubmittingWithStoredCard(payment)) { + const { nonce } = await this.braintreeHostedForm.tokenizeForStoredCardVerification(); + + return { + ...commonPaymentData, + ...paymentData, + nonce, + }; + } + + const { shouldSaveInstrument = false, shouldSetAsDefaultInstrument = false } = + isHostedInstrumentLike(paymentData) ? paymentData : {}; + + const { nonce } = this.shouldPerform3DSVerification(payment) + ? await this.verifyCardWithHostedForm(billingAddress, orderAmount) + : await this.braintreeHostedForm.tokenize(billingAddress); + + return { + ...commonPaymentData, + shouldSaveInstrument, + shouldSetAsDefaultInstrument, + nonce, + }; + } + + private async verifyCardWithHostedForm( + billingAddress: Address, + orderAmount: number, + ): Promise { + const tokenizationPayload = await this.braintreeHostedForm.tokenize(billingAddress); + + return this.braintreeIntegrationService.challenge3DSVerification( + tokenizationPayload, + orderAmount, + ); + } + + private async processAdditionalAction( + error: unknown, + payment: OrderPaymentRequestBody, + orderAmount: number, + ): Promise { + if ( + isBraintreePaymentRequest3DSError(error) && + (error.name !== 'RequestError' || + !some(error.body.errors, { code: 'three_d_secure_required' })) + ) { + return this.handleError(error); + } + + try { + const { payer_auth_request: storedCreditCardNonce } = + (isBraintreePaymentRequest3DSError(error) && error.body.three_ds_result) || {}; + const { paymentData } = payment; + const state = this.paymentIntegrationService.getState(); + + if (!paymentData || !isVaultedInstrument(paymentData)) { + throw new PaymentArgumentInvalidError(['instrumentId']); + } + + const instrument = state.getCardInstrumentOrThrow(paymentData.instrumentId); + const { nonce } = await this.braintreeIntegrationService.challenge3DSVerification( + { + nonce: storedCreditCardNonce || '', + bin: instrument.iin, + }, + orderAmount, + ); + + await this.paymentIntegrationService.submitPayment({ + ...payment, + paymentData: { + deviceSessionId: this.deviceSessionId, + nonce, + }, + }); + } catch (error) { + return this.handleError(error); + } + } + + private isHostedPaymentFormEnabled(methodId?: string, gatewayId?: string): boolean { + if (!methodId) { + return false; + } + + const state = this.paymentIntegrationService.getState(); + const paymentMethod = state.getPaymentMethodOrThrow(methodId, gatewayId); + + return paymentMethod.config.isHostedFormEnabled === true; + } + + private isSubmittingWithStoredCard(payment: OrderPaymentRequestBody): boolean { + return !!(payment.paymentData && isVaultedInstrument(payment.paymentData)); + } + + private shouldPerform3DSVerification(payment: OrderPaymentRequestBody): boolean { + return !!(this.is3dsEnabled && !this.isSubmittingWithStoredCard(payment)); + } + + // TODO: Remove when BT AXO A/B testing is finished + private shouldInitializeBraintreeFastlane(): boolean { + const state = this.paymentIntegrationService.getState(); + const paymentProviderCustomer = state.getPaymentProviderCustomerOrThrow(); + const braintreeCustomer = isBraintreeAcceleratedCheckoutCustomer(paymentProviderCustomer) + ? paymentProviderCustomer + : {}; + const isFastlaneEnabled: boolean = + this.paymentMethod?.initializationData.isAcceleratedCheckoutEnabled; + + return isFastlaneEnabled && !braintreeCustomer?.authenticationState; + } + + // TODO: Remove when BT AXO A/B testing is finished + private async initializeBraintreeFastlaneOrThrow(methodId: string): Promise { + const state = this.paymentIntegrationService.getState(); + const cart = state.getCartOrThrow(); + const paymentMethod = state.getPaymentMethodOrThrow(methodId); + const { clientToken, config } = paymentMethod; + + if (!clientToken) { + throw new MissingDataError(MissingDataErrorType.MissingPaymentMethod); + } + + this.braintreeIntegrationService.initialize(clientToken); + + await this.braintreeIntegrationService.getBraintreeFastlane(cart.id, config.testMode); + } +} diff --git a/packages/braintree-integration/src/braintree-credit-card/create-braintree-credit-card-payment-strategy.spec.ts b/packages/braintree-integration/src/braintree-credit-card/create-braintree-credit-card-payment-strategy.spec.ts new file mode 100644 index 0000000000..c61113f4c8 --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/create-braintree-credit-card-payment-strategy.spec.ts @@ -0,0 +1,19 @@ +import { PaymentIntegrationService } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { PaymentIntegrationServiceMock } from '@bigcommerce/checkout-sdk/payment-integrations-test-utils'; + +import createBraintreeCreditCardPaymentStrategy from './create-braintree-credit-card-payment-strategy'; +import BraintreeCreditCardPaymentStrategy from './braintree-credit-card-payment-strategy'; + +describe('createBraintreeCreditCardPaymentStrategy', () => { + let paymentIntegrationService: PaymentIntegrationService; + + beforeEach(() => { + paymentIntegrationService = new PaymentIntegrationServiceMock(); + }); + + it('instantiates braintree credit card payment strategy', () => { + const strategy = createBraintreeCreditCardPaymentStrategy(paymentIntegrationService); + + expect(strategy).toBeInstanceOf(BraintreeCreditCardPaymentStrategy); + }); +}); diff --git a/packages/braintree-integration/src/braintree-credit-card/create-braintree-credit-card-payment-strategy.ts b/packages/braintree-integration/src/braintree-credit-card/create-braintree-credit-card-payment-strategy.ts new file mode 100644 index 0000000000..629e03edc3 --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/create-braintree-credit-card-payment-strategy.ts @@ -0,0 +1,45 @@ +import { getScriptLoader } from '@bigcommerce/script-loader'; + +import { + BraintreeHostWindow, + BraintreeIntegrationService, + BraintreeScriptLoader, + BraintreeSDKVersionManager, +} from '@bigcommerce/checkout-sdk/braintree-utils'; + +import { + PaymentStrategyFactory, + toResolvableModule, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import BraintreeCreditCardPaymentStrategy from './braintree-credit-card-payment-strategy'; +import BraintreeHostedForm from '../braintree-hosted-form/braintree-hosted-form'; + +const createBraintreeCreditCardPaymentStrategy: PaymentStrategyFactory< + BraintreeCreditCardPaymentStrategy +> = (paymentIntegrationService) => { + const braintreeHostWindow: BraintreeHostWindow = window; + + const braintreeSDKVersionManager = new BraintreeSDKVersionManager(paymentIntegrationService); + + const braintreeScriptLoader = new BraintreeScriptLoader( + getScriptLoader(), + braintreeHostWindow, + braintreeSDKVersionManager, + ); + + const braintreeIntegrationService = new BraintreeIntegrationService( + braintreeScriptLoader, + braintreeHostWindow, + ); + + const braintreeHostedForm = new BraintreeHostedForm(braintreeScriptLoader); + + return new BraintreeCreditCardPaymentStrategy( + paymentIntegrationService, + braintreeIntegrationService, + braintreeHostedForm, + ); +}; + +export default toResolvableModule(createBraintreeCreditCardPaymentStrategy, [{ id: 'braintree' }]); diff --git a/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.spec.ts b/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.spec.ts new file mode 100644 index 0000000000..8e7c98b066 --- /dev/null +++ b/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.spec.ts @@ -0,0 +1,521 @@ +import { EventEmitter } from 'events'; + +import { + BraintreeFormOptions, + BraintreeHostedFields, + BraintreeScriptLoader, + BraintreeSDKVersionManager, + getClientMock, +} from '@bigcommerce/checkout-sdk/braintree-utils'; + +import { + NotInitializedError, + PaymentIntegrationService, + PaymentInvalidFormError, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { PaymentIntegrationServiceMock } from '@bigcommerce/checkout-sdk/payment-integrations-test-utils'; + +import { getScriptLoader } from '@bigcommerce/script-loader'; + +import { getBillingAddress } from '../mocks/braintree.mock'; + +import BraintreeHostedForm from './braintree-hosted-form'; + +describe('BraintreeHostedForm', () => { + let braintreeScriptLoader: BraintreeScriptLoader; + let cardFields: Pick; + let cardFieldsEventEmitter: EventEmitter; + let containers: HTMLElement[]; + let formOptions: BraintreeFormOptions; + let subject: BraintreeHostedForm; + let braintreeSDKVersionManager: BraintreeSDKVersionManager; + let paymentIntegrationService: PaymentIntegrationService; + + const unsupportedCardBrands = ['american-express', 'maestro']; + + function appendContainer(id: string): HTMLElement { + const container = document.createElement('div'); + container.id = id; + document.body.appendChild(container); + + return container; + } + + beforeEach(() => { + cardFieldsEventEmitter = new EventEmitter(); + + cardFields = { + on: jest.fn((eventName, callback) => { + cardFieldsEventEmitter.on(eventName, callback); + }), + tokenize: jest.fn(() => Promise.resolve({ nonce: 'foobar_nonce' })), + teardown: jest.fn(), + }; + + formOptions = { + fields: { + cardCode: { containerId: 'cardCode', placeholder: 'Card code' }, + cardName: { containerId: 'cardName', placeholder: 'Card name' }, + cardNumber: { containerId: 'cardNumber', placeholder: 'Card number' }, + cardExpiry: { containerId: 'cardExpiry', placeholder: 'Card expiry' }, + }, + styles: { + default: { color: '#000' }, + error: { color: '#f00', fontWeight: 'bold' }, + focus: { color: '#00f' }, + }, + }; + + paymentIntegrationService = new PaymentIntegrationServiceMock(); + braintreeSDKVersionManager = new BraintreeSDKVersionManager(paymentIntegrationService); + braintreeScriptLoader = new BraintreeScriptLoader( + getScriptLoader(), + window, + braintreeSDKVersionManager, + ); + subject = new BraintreeHostedForm(braintreeScriptLoader); + + containers = [ + appendContainer('cardCode'), + appendContainer('cardName'), + appendContainer('cardNumber'), + appendContainer('cardExpiry'), + ]; + + jest.spyOn(braintreeScriptLoader, 'loadClient').mockResolvedValue({ + create: jest.fn().mockResolvedValue(getClientMock()), + }); + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ on: jest.fn() }), + }); + }); + + afterEach(() => { + containers.forEach((container) => { + container.parentElement?.removeChild(container); + }); + }); + + describe('#initialize', () => { + it('creates and configures hosted fields', async () => { + const createMock = jest.fn(); + const clientMock = { + ...getClientMock(), + request: expect.any(Function), + }; + + const options = { + fields: { + cvv: { + container: '#cardCode', + internalLabel: undefined, + placeholder: 'Card code', + }, + expirationDate: { + container: '#cardExpiry', + internalLabel: undefined, + placeholder: 'Card expiry', + }, + number: { + container: '#cardNumber', + internalLabel: undefined, + placeholder: 'Card number', + supportedCardBrands: { + 'american-express': false, + maestro: false, + }, + }, + cardholderName: { + container: '#cardName', + internalLabel: undefined, + placeholder: 'Card name', + }, + }, + styles: { + input: { color: '#000' }, + '.invalid': { color: '#f00', 'font-weight': 'bold' }, + ':focus': { color: '#00f' }, + }, + }; + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: createMock, + }); + + await subject.initialize(formOptions, unsupportedCardBrands, 'clientToken'); + + expect(createMock).toHaveBeenCalledWith({ + ...options, + client: clientMock, + }); + }); + + it('creates and configures hosted fields for stored card verification', async () => { + const createMock = jest.fn(); + const clientMock = { + ...getClientMock(), + request: expect.any(Function), + }; + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: createMock, + }); + + const expectOptions = { + fields: { + cvv: { + container: '#cardCode', + placeholder: 'Card code', + }, + number: { + container: '#cardNumber', + placeholder: 'Card number', + }, + }, + styles: { + input: { color: '#000' }, + '.invalid': { color: '#f00', 'font-weight': 'bold' }, + ':focus': { color: '#00f' }, + }, + }; + + await subject.initialize( + { + ...formOptions, + fields: { + cardCodeVerification: { + containerId: 'cardCode', + placeholder: 'Card code', + instrumentId: 'foobar_instrument_id', + }, + cardNumberVerification: { + containerId: 'cardNumber', + placeholder: 'Card number', + instrumentId: 'foobar_instrument_id', + }, + }, + }, + [], + 'clientToken', + ); + + expect(createMock).toHaveBeenCalledWith({ + ...expectOptions, + client: clientMock, + }); + }); + }); + + describe('#isInitialized', () => { + it('returns true if hosted form is initialized', async () => { + await subject.initialize(formOptions, [], 'clientToken'); + expect(subject.isInitialized()).toBe(true); + }); + + it('returns false when no fields specified in form options', async () => { + await subject.initialize({ fields: {} }, [], 'clientToken'); + expect(subject.isInitialized()).toBe(false); + }); + + it('changes hosted form initialization state', async () => { + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + teardown: jest.fn(), + on: jest.fn(), + }), + }); + + await subject.initialize(formOptions, [], 'clientToken'); + expect(subject.isInitialized()).toBe(true); + + await subject.deinitialize(); + expect(subject.isInitialized()).toBe(false); + }); + }); + + describe('#deinitialize', () => { + it('calls hosted form fields teardown on deinitialize', async () => { + const teardownMock = jest.fn(); + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + teardown: teardownMock, + on: jest.fn(), + }), + }); + + await subject.initialize(formOptions, [], 'clientToken'); + await subject.deinitialize(); + + expect(teardownMock).toHaveBeenCalled(); + }); + + it('does not call teardown if fields are not initialized', async () => { + await subject.initialize({ ...formOptions, fields: {} }, [], 'clientToken'); + await subject.deinitialize(); + + expect(cardFields.teardown).not.toHaveBeenCalled(); + }); + }); + + describe('#tokenize', () => { + it('tokenizes data through hosted fields', async () => { + const tokenizeMock = jest.fn().mockResolvedValue({ nonce: 'nonce' }); + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + teardown: jest.fn(), + on: jest.fn(), + tokenize: tokenizeMock, + }), + }); + + await subject.initialize(formOptions, [], 'clientToken'); + const billingAddress = getBillingAddress(); + + await subject.tokenize(billingAddress); + + expect(tokenizeMock).toHaveBeenCalledWith({ + billingAddress: { + countryName: billingAddress.country, + postalCode: billingAddress.postalCode, + streetAddress: billingAddress.address1, + }, + }); + }); + + it('returns invalid form error when tokenizing with invalid form data', async () => { + const tokenizeMock = jest.fn().mockRejectedValue({ + name: 'BraintreeError', + code: 'HOSTED_FIELDS_FIELDS_EMPTY', + }); + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + teardown: jest.fn(), + on: jest.fn(), + tokenize: tokenizeMock, + }), + }); + + await subject.initialize(formOptions, [], 'clientToken'); + + await expect(subject.tokenize(getBillingAddress())).rejects.toBeInstanceOf( + PaymentInvalidFormError, + ); + }); + + it('throws error if trying to tokenize before initialization', async () => { + await expect(subject.tokenize(getBillingAddress())).rejects.toBeInstanceOf( + NotInitializedError, + ); + }); + }); + + describe('#tokenizeForStoredCardVerification', () => { + it('tokenizes data for stored card verification', async () => { + const tokenizeMock = jest.fn().mockResolvedValue({ nonce: 'nonce' }); + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + teardown: jest.fn(), + on: jest.fn(), + tokenize: tokenizeMock, + }), + }); + + await subject.initialize(formOptions, [], 'clientToken'); + await subject.tokenizeForStoredCardVerification(); + + expect(tokenizeMock).toHaveBeenCalled(); + }); + + it('returns invalid form error on invalid stored card data', async () => { + const tokenizeMock = jest.fn().mockRejectedValue({ + name: 'BraintreeError', + code: 'HOSTED_FIELDS_FIELDS_EMPTY', + }); + + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + teardown: jest.fn(), + on: jest.fn(), + tokenize: tokenizeMock, + }), + }); + + await subject.initialize(formOptions, [], 'clientToken'); + + await expect(subject.tokenizeForStoredCardVerification()).rejects.toBeInstanceOf( + PaymentInvalidFormError, + ); + }); + + it('throws error if trying to tokenize before initialization', async () => { + await expect(subject.tokenizeForStoredCardVerification()).rejects.toBeInstanceOf( + NotInitializedError, + ); + }); + }); + + describe('card fields events notifications', () => { + let handleFocus: jest.Mock; + let handleBlur: jest.Mock; + let handleEnter: jest.Mock; + let handleCardTypeChange: jest.Mock; + let handleValidate: jest.Mock; + + beforeEach(async () => { + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + on: jest.fn((eventName, callback) => { + cardFieldsEventEmitter.on(eventName, callback); + }), + tokenize: jest.fn(() => Promise.resolve({ nonce: 'foobar_nonce' })), + teardown: jest.fn(), + }), + }); + + handleFocus = jest.fn(); + handleBlur = jest.fn(); + handleEnter = jest.fn(); + handleCardTypeChange = jest.fn(); + handleValidate = jest.fn(); + + await subject.initialize( + { + ...formOptions, + onFocus: handleFocus, + onBlur: handleBlur, + onEnter: handleEnter, + onCardTypeChange: handleCardTypeChange, + onValidate: handleValidate, + }, + [], + 'clientToken', + ); + }); + + it('notifies on focus', () => { + cardFieldsEventEmitter.emit('focus', { emittedBy: 'cvv' }); + expect(handleFocus).toHaveBeenCalledWith({ fieldType: 'cardCode' }); + }); + + it('notifies on blur', () => { + cardFieldsEventEmitter.emit('blur', { emittedBy: 'cvv' }); + expect(handleBlur).toHaveBeenCalledWith({ fieldType: 'cardCode', errors: {} }); + }); + + it('notifies on blur with field errors', () => { + cardFieldsEventEmitter.emit('blur', { + emittedBy: 'cvv', + fields: { cvv: { isEmpty: true, isPotentiallyValid: true, isValid: false } }, + }); + + expect(handleBlur).toHaveBeenCalledWith({ + fieldType: 'cardCode', + errors: { + cvv: { + isEmpty: true, + isPotentiallyValid: true, + isValid: false, + }, + }, + }); + }); + + it('notifies on enter key', () => { + cardFieldsEventEmitter.emit('inputSubmitRequest', { emittedBy: 'cvv' }); + expect(handleEnter).toHaveBeenCalledWith({ fieldType: 'cardCode' }); + }); + + it('notifies on card type change', () => { + cardFieldsEventEmitter.emit('cardTypeChange', { cards: [{ type: 'visa' }] }); + expect(handleCardTypeChange).toHaveBeenCalledWith({ cardType: 'visa' }); + }); + + it('normalizes "master-card" to "mastercard"', () => { + cardFieldsEventEmitter.emit('cardTypeChange', { cards: [{ type: 'master-card' }] }); + expect(handleCardTypeChange).toHaveBeenCalledWith({ cardType: 'mastercard' }); + }); + + it('notifies undefined if card type is ambiguous', () => { + cardFieldsEventEmitter.emit('cardTypeChange', { + cards: [{ type: 'visa' }, { type: 'master-card' }], + }); + + expect(handleCardTypeChange).toHaveBeenCalledWith({ cardType: undefined }); + }); + + it('notifies on validation errors', () => { + cardFieldsEventEmitter.emit('validityChange', { + fields: { + cvv: { isValid: false }, + number: { isValid: false }, + expirationDate: { isValid: false }, + }, + }); + + expect(handleValidate).toHaveBeenCalledWith({ + errors: { + cardCode: [ + { + fieldType: 'cardCode', + message: 'Invalid card code', + type: 'card', + }, + ], + cardNumber: [ + { + fieldType: 'cardNumber', + message: 'Invalid card number', + type: 'card', + }, + ], + cardExpiry: [ + { + fieldType: 'cardExpiry', + message: 'Invalid card expiry', + type: 'card', + }, + ], + }, + isValid: false, + }); + }); + + it('notifies when form becomes valid', () => { + cardFieldsEventEmitter.emit('validityChange', { + fields: { + cvv: { isValid: true }, + number: { isValid: true }, + expirationDate: { isValid: true }, + }, + }); + + expect(handleValidate).toHaveBeenCalledWith({ + errors: { + cardCode: undefined, + cardNumber: undefined, + cardExpiry: undefined, + }, + isValid: true, + }); + }); + + it('notifies when tokenizing valid form', async () => { + await subject.tokenize(getBillingAddress()); + + expect(handleValidate).toHaveBeenCalledWith({ + errors: { + cardCode: undefined, + cardNumber: undefined, + cardExpiry: undefined, + }, + isValid: true, + }); + }); + }); +}); diff --git a/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.ts b/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.ts new file mode 100644 index 0000000000..7acd734dc3 --- /dev/null +++ b/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.ts @@ -0,0 +1,463 @@ +import { Dictionary, isEmpty, isNil, omitBy } from 'lodash'; + +import { + BraintreeBillingAddressRequestData, + BraintreeClient, + BraintreeFormErrorDataKeys, + BraintreeFormErrorsData, + BraintreeFormFieldsMap, + BraintreeFormFieldStyles, + BraintreeFormFieldStylesMap, + BraintreeFormFieldType, + BraintreeFormFieldValidateErrorData, + BraintreeFormFieldValidateEventData, + BraintreeFormOptions, + BraintreeHostedFields, + BraintreeHostedFieldsCreatorConfig, + BraintreeHostedFieldsState, + BraintreeHostedFormError, + BraintreeScriptLoader, + BraintreeStoredCardFieldsMap, + isBraintreeFormFieldsMap, + isBraintreeHostedFormError, + isBraintreeSupportedCardBrand, + TokenizationPayload, +} from '@bigcommerce/checkout-sdk/braintree-utils'; + +import { + Address, + NotInitializedError, + NotInitializedErrorType, + PaymentInvalidFormError, + PaymentInvalidFormErrorDetails, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +enum BraintreeHostedFormType { + CreditCard, + StoredCardVerification, +} + +export default class BraintreeHostedForm { + private cardFields?: BraintreeHostedFields; + private formOptions?: BraintreeFormOptions; + private type?: BraintreeHostedFormType; + private client?: Promise; + private clientToken?: string; + private isInitializedHostedForm = false; + + constructor(private braintreeScriptLoader: BraintreeScriptLoader) {} + + async initialize( + options: BraintreeFormOptions, + unsupportedCardBrands?: string[], + clientToken?: string, + ): Promise { + this.clientToken = clientToken; + this.formOptions = options; + this.type = isBraintreeFormFieldsMap(options.fields) + ? BraintreeHostedFormType.CreditCard + : BraintreeHostedFormType.StoredCardVerification; + + const fields = this.mapFieldOptions(options.fields, unsupportedCardBrands); + + if (isEmpty(fields)) { + this.isInitializedHostedForm = false; + return; + } + + this.cardFields = await this.createHostedFields({ + fields, + styles: options.styles && this.mapStyleOptions(options.styles), + }); + + this.cardFields?.on('blur', this.handleBlur); + this.cardFields?.on('focus', this.handleFocus); + this.cardFields?.on('cardTypeChange', this.handleCardTypeChange); + this.cardFields?.on('validityChange', this.handleValidityChange); + this.cardFields?.on('inputSubmitRequest', this.handleInputSubmitRequest); + + this.isInitializedHostedForm = true; + } + + isInitialized(): boolean { + return !!this.isInitializedHostedForm; + } + + async deinitialize(): Promise { + if (this.isInitializedHostedForm) { + this.isInitializedHostedForm = false; + await this.cardFields?.teardown(); + } + } + + validate(): void { + if (!this.cardFields) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + const state = this.cardFields.getState(); + + if (!this.isValidForm(state)) { + this.handleValidityChange(state); + + const errors = this.mapValidationErrors(state.fields); + throw new PaymentInvalidFormError(errors as PaymentInvalidFormErrorDetails); + } + } + + async tokenize(billingAddress: Address): Promise { + if (!this.cardFields) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + try { + const payload = await this.cardFields.tokenize( + omitBy( + { + billingAddress: billingAddress && this.mapBillingAddress(billingAddress), + }, + isNil, + ), + ); + + this.formOptions?.onValidate?.({ isValid: true, errors: {} }); + + return { + nonce: payload.nonce, + bin: payload.details?.bin, + }; + } catch (error) { + if (isBraintreeHostedFormError(error)) { + const errors = this.mapTokenizeError(error); + + if (errors) { + this.formOptions?.onValidate?.({ isValid: false, errors }); + throw new PaymentInvalidFormError(errors as PaymentInvalidFormErrorDetails); + } + } + + throw error; + } + } + + async tokenizeForStoredCardVerification(): Promise { + if (!this.cardFields) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + try { + const payload = await this.cardFields.tokenize(); + + this.formOptions?.onValidate?.({ isValid: true, errors: {} }); + + return { + nonce: payload.nonce, + bin: payload.details?.bin, + }; + } catch (error) { + if (isBraintreeHostedFormError(error)) { + const errors = this.mapTokenizeError(error, true); + + if (errors) { + this.formOptions?.onValidate?.({ isValid: false, errors }); + throw new PaymentInvalidFormError(errors as PaymentInvalidFormErrorDetails); + } + } + + throw error; + } + } + + async createHostedFields( + options: Pick, + ): Promise { + const client = await this.getClient(); + const hostedFields = await this.braintreeScriptLoader.loadHostedFields(); + + return hostedFields.create({ ...options, client }); + } + + async getClient(): Promise { + if (!this.clientToken) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + if (!this.client) { + const client = await this.braintreeScriptLoader.loadClient(); + this.client = client.create({ authorization: this.clientToken }); + } + + return this.client; + } + + private mapBillingAddress(billingAddress: Address): BraintreeBillingAddressRequestData { + return { + countryName: billingAddress.country, + postalCode: billingAddress.postalCode, + streetAddress: billingAddress.address2 + ? `${billingAddress.address1} ${billingAddress.address2}` + : billingAddress.address1, + }; + } + + private mapFieldOptions( + fields: BraintreeFormFieldsMap | BraintreeStoredCardFieldsMap, + unsupportedCardBrands?: string[], + ): BraintreeHostedFieldsCreatorConfig['fields'] { + if (isBraintreeFormFieldsMap(fields)) { + const supportedCardBrands: Partial> = {}; + + unsupportedCardBrands?.forEach((cardBrand) => { + if (isBraintreeSupportedCardBrand(cardBrand)) { + supportedCardBrands[cardBrand] = false; + } + }); + + return omitBy( + { + number: { + container: `#${fields.cardNumber.containerId}`, + placeholder: fields.cardNumber.placeholder, + internalLabel: fields.cardNumber.accessibilityLabel, + ...(Object.keys(supportedCardBrands).length > 0 + ? { supportedCardBrands } + : {}), + }, + expirationDate: { + container: `#${fields.cardExpiry.containerId}`, + placeholder: fields.cardExpiry.placeholder, + internalLabel: fields.cardExpiry.accessibilityLabel, + }, + cvv: fields.cardCode && { + container: `#${fields.cardCode.containerId}`, + placeholder: fields.cardCode.placeholder, + internalLabel: fields.cardCode.accessibilityLabel, + }, + cardholderName: { + container: `#${fields.cardName.containerId}`, + placeholder: fields.cardName.placeholder, + internalLabel: fields.cardName.accessibilityLabel, + }, + }, + isNil, + ); + } + + return omitBy( + { + number: fields.cardNumberVerification && { + container: `#${fields.cardNumberVerification.containerId}`, + placeholder: fields.cardNumberVerification.placeholder, + }, + cvv: fields.cardCodeVerification && { + container: `#${fields.cardCodeVerification.containerId}`, + placeholder: fields.cardCodeVerification.placeholder, + }, + }, + isNil, + ); + } + + private mapStyleOptions( + options: BraintreeFormFieldStylesMap, + ): BraintreeHostedFieldsCreatorConfig['styles'] { + const mapStyles = (styles: BraintreeFormFieldStyles = {}) => + omitBy( + { + color: styles.color, + 'font-family': styles.fontFamily, + 'font-size': styles.fontSize, + 'font-weight': styles.fontWeight, + }, + isNil, + ) as Dictionary; + + return { + input: mapStyles(options.default), + '.invalid': mapStyles(options.error), + ':focus': mapStyles(options.focus), + }; + } + + private mapFieldType(type: string): BraintreeFormFieldType { + switch (type) { + case 'number': + return this.type === BraintreeHostedFormType.StoredCardVerification + ? BraintreeFormFieldType.CardNumberVerification + : BraintreeFormFieldType.CardNumber; + + case 'expirationDate': + return BraintreeFormFieldType.CardExpiry; + + case 'cvv': + return this.type === BraintreeHostedFormType.StoredCardVerification + ? BraintreeFormFieldType.CardCodeVerification + : BraintreeFormFieldType.CardCode; + + case 'cardholderName': + return BraintreeFormFieldType.CardName; + + default: + throw new Error('Unexpected field type'); + } + } + + private mapErrors(fields: BraintreeHostedFieldsState['fields']): BraintreeFormErrorsData { + const errors: BraintreeFormErrorsData = {}; + + if (fields) { + // eslint-disable-next-line no-restricted-syntax + for (const [key, value] of Object.entries(fields)) { + if (value && this.isValidParam(key)) { + const { isValid, isEmpty, isPotentiallyValid } = value; + + errors[key] = { + isValid, + isEmpty, + isPotentiallyValid, + }; + } + } + } + + return errors; + } + + private mapValidationErrors( + fields: BraintreeHostedFieldsState['fields'], + ): BraintreeFormFieldValidateEventData['errors'] { + return (Object.keys(fields) as Array).reduce( + (result, fieldKey) => ({ + ...result, + [this.mapFieldType(fieldKey)]: fields[fieldKey]?.isValid + ? undefined + : [this.createInvalidError(this.mapFieldType(fieldKey))], + }), + {}, + ); + } + + private mapTokenizeError( + error: BraintreeHostedFormError, + isStoredCard = false, + ): BraintreeFormFieldValidateEventData['errors'] | undefined { + if (error.code === 'HOSTED_FIELDS_FIELDS_EMPTY') { + const cvv = [this.createRequiredError(this.mapFieldType('cvv'))]; + + if (isStoredCard) { + return { [this.mapFieldType('cvv')]: cvv }; + } + + return { + [this.mapFieldType('cvv')]: cvv, + [this.mapFieldType('expirationDate')]: [ + this.createRequiredError(this.mapFieldType('expirationDate')), + ], + [this.mapFieldType('number')]: [ + this.createRequiredError(this.mapFieldType('number')), + ], + [this.mapFieldType('cardholderName')]: [ + this.createRequiredError(this.mapFieldType('cardholderName')), + ], + }; + } + + return error.details?.invalidFieldKeys?.reduce((result, key) => { + const type = this.mapFieldType(key); + return { + ...result, + [type]: [this.createInvalidError(type)], + }; + }, {}); + } + + private createRequiredError( + fieldType: BraintreeFormFieldType, + ): BraintreeFormFieldValidateErrorData { + const messages = { + [BraintreeFormFieldType.CardCode]: 'CVV is required', + [BraintreeFormFieldType.CardCodeVerification]: 'CVV is required', + [BraintreeFormFieldType.CardNumber]: 'Credit card number is required', + [BraintreeFormFieldType.CardNumberVerification]: 'Credit card number is required', + [BraintreeFormFieldType.CardExpiry]: 'Expiration date is required', + [BraintreeFormFieldType.CardName]: 'Full name is required', + }; + + return { + fieldType, + message: messages[fieldType] ?? 'Field is required', + type: 'required', + }; + } + + private createInvalidError( + fieldType: BraintreeFormFieldType, + ): BraintreeFormFieldValidateErrorData { + const messages = { + [BraintreeFormFieldType.CardCode]: 'Invalid card code', + [BraintreeFormFieldType.CardCodeVerification]: 'Invalid card code', + [BraintreeFormFieldType.CardNumber]: 'Invalid card number', + [BraintreeFormFieldType.CardNumberVerification]: 'Invalid card number', + [BraintreeFormFieldType.CardExpiry]: 'Invalid card expiry', + [BraintreeFormFieldType.CardName]: 'Invalid card name', + }; + + return { + fieldType, + message: messages[fieldType] ?? 'Invalid field', + type: messages[fieldType]?.split(' ')[1] || 'invalid', + }; + } + + private handleBlur = (event: BraintreeHostedFieldsState): void => { + this.formOptions?.onBlur?.({ + fieldType: this.mapFieldType(event.emittedBy), + errors: this.mapErrors(event.fields), + }); + }; + + private handleFocus = (event: BraintreeHostedFieldsState): void => { + this.formOptions?.onFocus?.({ + fieldType: this.mapFieldType(event.emittedBy), + }); + }; + + private handleCardTypeChange = (event: BraintreeHostedFieldsState): void => { + const cardType = + event.cards.length === 1 + ? event.cards[0].type.replace(/^master-card$/, 'mastercard') + : undefined; + + this.formOptions?.onCardTypeChange?.({ cardType }); + }; + + private handleInputSubmitRequest = (event: BraintreeHostedFieldsState): void => { + this.formOptions?.onEnter?.({ + fieldType: this.mapFieldType(event.emittedBy), + }); + }; + + private handleValidityChange = (event: BraintreeHostedFieldsState): void => { + this.formOptions?.onValidate?.({ + isValid: this.isValidForm(event), + errors: this.mapValidationErrors(event.fields), + }); + }; + + private isValidForm(event: BraintreeHostedFieldsState): boolean { + return ( + Object.keys(event.fields) as Array + ).every((key) => event.fields[key]?.isValid); + } + + private isValidParam(key: string): key is BraintreeFormErrorDataKeys { + return [ + 'number', + 'cvv', + 'expirationDate', + 'postalCode', + 'cardholderName', + 'cardType', + ].includes(key); + } +} diff --git a/packages/braintree-integration/src/index.ts b/packages/braintree-integration/src/index.ts index 0c24f1dc20..601c30cf90 100644 --- a/packages/braintree-integration/src/index.ts +++ b/packages/braintree-integration/src/index.ts @@ -45,3 +45,8 @@ export { default as createBraintreeVisaCheckoutCustomerStrategy } from './braint * Braintree Venmo */ export { default as createBraintreeVenmoButtonStrategy } from './braintree-venmo/create-braintree-venmo-button-strategy'; + +/** + * Braintree Credit Card Payment Strategies + */ +export { default as createBraintreeCreditCardPaymentStrategy } from './braintree-credit-card/create-braintree-credit-card-payment-strategy'; diff --git a/packages/braintree-integration/src/mocks/braintree.mock.ts b/packages/braintree-integration/src/mocks/braintree.mock.ts index 511c69a403..1f8f479530 100644 --- a/packages/braintree-integration/src/mocks/braintree.mock.ts +++ b/packages/braintree-integration/src/mocks/braintree.mock.ts @@ -1,6 +1,11 @@ import { Braintree3DSVerifyCardCallback, + BraintreeClient, + BraintreeHostedFieldsTokenizePayload, + BraintreeModule, + BraintreeModuleCreator, BraintreeThreeDSecure, + BraintreeThreeDSecureOptions, PaypalButtonStyleColorOption, } from '@bigcommerce/checkout-sdk/braintree-utils'; @@ -11,6 +16,7 @@ import { export function getBraintreeAcceleratedCheckoutPaymentMethod(): PaymentMethod { return { + skipRedirectConfirmationAlert: true, id: 'braintreeacceleratedcheckout', logoUrl: '', method: 'credit-card', @@ -76,3 +82,82 @@ export function getBraintreeLocalMethods() { type: 'PAYMENT_TYPE_API', }; } + +export interface BraintreeTokenizeResponse { + creditCards: BraintreeHostedFieldsTokenizePayload[]; +} + +export function getTokenizeResponseBody(): BraintreeTokenizeResponse { + return { + creditCards: [ + { + nonce: 'demo_nonce', + details: { + bin: 'demo_bin', + cardType: 'Visa', + expirationMonth: '01', + expirationYear: '2025', + lastFour: '0001', + lastTwo: '01', + }, + description: 'ending in 01', + type: 'CreditCard', + binData: { + commercial: 'bin_data_commercial', + countryOfIssuance: 'bin_data_country_of_issuance', + debit: 'bin_data_debit', + durbinRegulated: 'bin_data_durbin_regulated', + healthcare: 'bin_data_healthcare', + issuingBank: 'bin_data_issuing_bank', + payroll: 'bin_data_payroll', + prepaid: 'bin_data_prepaid', + productId: 'bin_data_product_id', + }, + }, + ], + }; +} + +export function getThreeDSecureOptionsMock(): BraintreeThreeDSecureOptions { + return { + nonce: 'nonce', + amount: 225, + addFrame: jest.fn(), + removeFrame: jest.fn(), + additionalInformation: { + acsWindowSize: '01', + }, + }; +} + +export function getModuleCreatorMock( + module: BraintreeModule | BraintreeClient, +): BraintreeModuleCreator { + return { + // TODO: remove ts-ignore and update test with related type (PAYPAL-4383) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + create: jest.fn(() => Promise.resolve(module)), + }; +} + +export function getBillingAddress() { + return { + id: '55c96cda6f04c', + firstName: 'Test', + lastName: 'Tester', + email: 'test@bigcommerce.com', + company: 'Bigcommerce', + address1: '12345 Testing Way', + address2: '', + city: 'Some City', + stateOrProvince: 'California', + stateOrProvinceCode: 'CA', + country: 'United States', + countryCode: 'US', + postalCode: '95555', + shouldSaveAddress: true, + phone: '555-555-5555', + customFields: [], + }; +} diff --git a/packages/braintree-utils/src/braintree-integration-service.spec.ts b/packages/braintree-utils/src/braintree-integration-service.spec.ts index ef9299bde7..f4be6afc8d 100644 --- a/packages/braintree-utils/src/braintree-integration-service.spec.ts +++ b/packages/braintree-utils/src/braintree-integration-service.spec.ts @@ -13,7 +13,9 @@ import BraintreeIntegrationService from './braintree-integration-service'; import BraintreeScriptLoader from './braintree-script-loader'; import BraintreeSDKVersionManager from './braintree-sdk-version-manager'; import { + getBillingAddress, getBraintreeAddress, + getBraintreePaymentData, getClientMock, getDataCollectorMock, getDeviceDataMock, @@ -546,6 +548,64 @@ describe('BraintreeIntegrationService', () => { }); }); + describe('#verifyCard', () => { + const threeDSecureOptions = { + nonce: '3ds_nonce', + amount: 122, + }; + it('tokenizes the card with the right params', async () => { + jest.spyOn(braintreeIntegrationService, 'tokenizeCard'); + jest.spyOn(braintreeIntegrationService, 'verifyCard'); + jest.spyOn(braintreeScriptLoader, 'load3DS'); + jest.spyOn(braintreeScriptLoader, 'loadClient').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + request: jest.fn().mockResolvedValue({ + creditCards: [ + { + nonce: 'nonce', + details: { bin: 'bin' }, + }, + ], + }), + }), + }); + braintreeIntegrationService.initialize(clientToken, threeDSecureOptions); + await braintreeIntegrationService.verifyCard( + getBraintreePaymentData(), + getBillingAddress(), + 122, + ); + + expect(braintreeIntegrationService.tokenizeCard).toHaveBeenCalledWith( + getBraintreePaymentData(), + getBillingAddress(), + ); + }); + + it('loads 3ds', async () => { + jest.spyOn(braintreeScriptLoader, 'loadClient').mockResolvedValue({ + create: jest.fn().mockResolvedValue({ + request: jest.fn().mockResolvedValue({ + creditCards: [ + { + nonce: 'nonce', + details: { bin: 'bin' }, + }, + ], + }), + }), + }); + braintreeIntegrationService.initialize(clientToken, threeDSecureOptions); + await braintreeIntegrationService.verifyCard( + getBraintreePaymentData(), + getBillingAddress(), + 122, + ); + + expect(braintreeScriptLoader.load3DS).toHaveBeenCalled(); + }); + }); + describe('#teardown()', () => { it('calls teardown in all the dependencies', async () => { braintreeIntegrationService.initialize(clientToken); diff --git a/packages/braintree-utils/src/braintree-integration-service.ts b/packages/braintree-utils/src/braintree-integration-service.ts index acf920af87..4ecfd6129f 100644 --- a/packages/braintree-utils/src/braintree-integration-service.ts +++ b/packages/braintree-utils/src/braintree-integration-service.ts @@ -2,9 +2,17 @@ import { supportsPopups } from '@braintree/browser-detection'; import { Address, + CancellablePromise, + CreditCardInstrument, LegacyAddress, + NonceInstrument, NotInitializedError, NotInitializedErrorType, + Payment, + PaymentArgumentInvalidError, + PaymentInvalidFormError, + PaymentInvalidFormErrorDetails, + PaymentMethodCancelledError, } from '@bigcommerce/checkout-sdk/payment-integration-api'; import { Overlay } from '@bigcommerce/checkout-sdk/ui'; @@ -23,12 +31,19 @@ import { BraintreePaypal, BraintreePaypalCheckout, BraintreePaypalSdkCreatorConfig, + BraintreeRequestData, BraintreeShippingAddressOverride, + BraintreeThreeDSecure, + BraintreeThreeDSecureOptions, BraintreeTokenizationDetails, BraintreeTokenizePayload, + BraintreeVerifyPayload, PAYPAL_COMPONENTS, + TokenizationPayload, } from './types'; import isBraintreeError from './utils/is-braintree-error'; +import { isEmpty } from 'lodash'; +import isCreditCardInstrumentLike from './utils/is-credit-card-instrument-like'; export interface PaypalConfig { amount: number; @@ -47,6 +62,8 @@ export default class BraintreeIntegrationService { private dataCollectors: BraintreeDataCollectors = {}; private paypalCheckout?: BraintreePaypalCheckout; private braintreePaypal?: Promise; + private threeDSecureOptions?: BraintreeThreeDSecureOptions; + private threeDS?: Promise; constructor( private braintreeScriptLoader: BraintreeScriptLoader, @@ -54,8 +71,9 @@ export default class BraintreeIntegrationService { private overlay?: Overlay, ) {} - initialize(clientToken: string) { + initialize(clientToken: string, threeDSecureOptions?: BraintreeThreeDSecureOptions) { this.clientToken = clientToken; + this.threeDSecureOptions = threeDSecureOptions; } async getBraintreeFastlane( @@ -308,6 +326,62 @@ export default class BraintreeIntegrationService { // this._visaCheckout = undefined; } + async get3DS(): Promise { + if (!this.threeDS) { + this.threeDS = Promise.all([ + this.getClient(), + this.braintreeScriptLoader.load3DS(), + ]).then(([client, threeDSecure]) => threeDSecure.create({ client, version: 2 })); + } + + return this.threeDS; + } + + /* + Braintree Credit Card and Braintree Hosted Form + */ + async verifyCard( + payment: Payment, + billingAddress: Address, + amount: number, + ): Promise { + const tokenizationPayload = await this.tokenizeCard(payment, billingAddress); + + return this.challenge3DSVerification(tokenizationPayload, amount); + } + + async tokenizeCard(payment: Payment, billingAddress: Address): Promise { + const { paymentData } = payment; + + if (!isCreditCardInstrumentLike(paymentData)) { + throw new PaymentArgumentInvalidError(['payment.paymentData']); + } + + const errors = this.getErrorsRequiredFields(paymentData); + + if (!isEmpty(errors)) { + throw new PaymentInvalidFormError(errors); + } + + const requestData = this.mapToCreditCard(paymentData, billingAddress); + const client = await this.getClient(); + const { creditCards } = await client.request(requestData); + + return { + nonce: creditCards[0].nonce, + bin: creditCards[0].details.bin, + }; + } + + async challenge3DSVerification( + tokenizationPayload: TokenizationPayload, + amount: number, + ): Promise { + const threeDSecure = await this.get3DS(); + + return this.present3DSChallenge(threeDSecure, amount, tokenizationPayload); + } + private teardownModule(module?: BraintreeModule) { return module ? module.teardown() : Promise.resolve(); } @@ -319,4 +393,112 @@ export default class BraintreeIntegrationService { return this.clientToken; } + + private getErrorsRequiredFields( + paymentData: CreditCardInstrument, + ): PaymentInvalidFormErrorDetails { + const { ccNumber, ccExpiry } = paymentData; + const errors: PaymentInvalidFormErrorDetails = {}; + + if (!ccNumber) { + errors.ccNumber = [ + { + message: 'Credit card number is required', + type: 'required', + }, + ]; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!ccExpiry) { + errors.ccExpiry = [ + { + message: 'Expiration date is required', + type: 'required', + }, + ]; + } + + return errors; + } + + private mapToCreditCard( + creditCard: CreditCardInstrument, + billingAddress?: Address, + ): BraintreeRequestData { + return { + data: { + creditCard: { + cardholderName: creditCard.ccName, + number: creditCard.ccNumber, + cvv: creditCard.ccCvv, + expirationDate: `${creditCard.ccExpiry.month}/${creditCard.ccExpiry.year}`, + options: { + validate: false, + }, + billingAddress: billingAddress && { + countryCodeAlpha2: billingAddress.countryCode, + locality: billingAddress.city, + countryName: billingAddress.country, + postalCode: billingAddress.postalCode, + streetAddress: billingAddress.address2 + ? `${billingAddress.address1} ${billingAddress.address2}` + : billingAddress.address1, + }, + }, + }, + endpoint: 'payment_methods/credit_cards', + method: 'post', + }; + } + + private present3DSChallenge( + threeDSecure: BraintreeThreeDSecure, + amount: number, + tokenizationPayload: TokenizationPayload, + ): Promise { + const { nonce, bin } = tokenizationPayload; + + if (!this.threeDSecureOptions || !nonce) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + const { + addFrame, + removeFrame, + challengeRequested = true, + additionalInformation, + } = this.threeDSecureOptions; + const cancelVerifyCard = async () => { + const response = await threeDSecure.cancelVerifyCard(); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + verification.cancel(new PaymentMethodCancelledError()); + + return response; + }; + + const roundedAmount = amount.toFixed(2); + + const verification = new CancellablePromise( + threeDSecure.verifyCard({ + addFrame: (error, iframe) => { + if (addFrame) { + addFrame(error, iframe, cancelVerifyCard); + } + }, + amount: Number(roundedAmount), + bin, + challengeRequested, + nonce, + removeFrame, + onLookupComplete: (_data, next) => { + next(); + }, + collectDeviceData: true, + additionalInformation, + }), + ); + + return verification.promise; + } } diff --git a/packages/braintree-utils/src/index.ts b/packages/braintree-utils/src/index.ts index f51fb2a154..5388b91b28 100644 --- a/packages/braintree-utils/src/index.ts +++ b/packages/braintree-utils/src/index.ts @@ -13,3 +13,8 @@ export { BRAINTREE_SDK_STABLE_VERSION } from './braintree-sdk-verison'; export { default as mapToLegacyBillingAddress } from './map-to-legacy-billing-address'; export { default as mapToLegacyShippingAddress } from './map-to-legacy-shipping-address'; + +export { + isBraintreeFormFieldsMap, + isBraintreeStoredCardFieldsMap, +} from './utils/is-braintree-form-fields-map'; diff --git a/packages/braintree-utils/src/mocks/braintree.mock.ts b/packages/braintree-utils/src/mocks/braintree.mock.ts index 41b65042a5..7547923766 100644 --- a/packages/braintree-utils/src/mocks/braintree.mock.ts +++ b/packages/braintree-utils/src/mocks/braintree.mock.ts @@ -1,6 +1,10 @@ -import { PaymentMethod } from '@bigcommerce/checkout-sdk/payment-integration-api'; - import { + OrderPaymentRequestBody, + PaymentMethod, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import BillingAddress, { + BillingAddressState, BraintreeFastlane, BraintreeFastlaneAuthenticationState, BraintreeFastlaneProfileData, @@ -22,6 +26,7 @@ import { } from '../types'; import { getVisaCheckoutTokenizedPayload } from './visacheckout.mock'; +import { getOrderRequestBody } from '@bigcommerce/checkout-sdk/payment-integrations-test-utils'; export function getBraintree(): PaymentMethod { return { @@ -111,9 +116,69 @@ export function getVisaCheckoutMock(): BraintreeVisaCheckout { }; } +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const mockHtmlElement = { + tagName: 'DIV', +} as unknown as HTMLElement; + +const hostedFieldMock = { + container: mockHtmlElement, + isFocused: true, + isEmpty: true, + isPotentiallyValid: true, + isValid: true, +}; + +export function getBraintreePaymentData(): OrderPaymentRequestBody { + return { + ...getOrderRequestBody().payment, + methodId: 'braintree', + }; +} + +export function getBillingAddress(): BillingAddress { + return { + id: '55c96cda6f04c', + firstName: 'Test', + lastName: 'Tester', + email: 'test@bigcommerce.com', + company: 'Bigcommerce', + address1: '12345 Testing Way', + address2: '', + city: 'Some City', + stateOrProvince: 'California', + stateOrProvinceCode: 'CA', + country: 'United States', + countryCode: 'US', + postalCode: '95555', + shouldSaveAddress: true, + phone: '555-555-5555', + customFields: [], + }; +} + +export function getBillingAddressState(): BillingAddressState { + return { + data: getBillingAddress(), + errors: {}, + statuses: {}, + }; +} + export function getHostedFieldsMock(): BraintreeHostedFields { return { - getState: jest.fn(), + getState: () => ({ + cards: [], + emittedBy: 'bank', + fields: { + number: hostedFieldMock, + expirationDate: hostedFieldMock, + expirationMonth: hostedFieldMock, + expirationYear: hostedFieldMock, + cvv: hostedFieldMock, + postalCode: hostedFieldMock, + }, + }), teardown: jest.fn(() => Promise.resolve()), // TODO: remove ts-ignore and update test with related type (PAYPAL-4383) // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/braintree-utils/src/types.ts b/packages/braintree-utils/src/types.ts index 564d7b36f2..a15203ef3e 100644 --- a/packages/braintree-utils/src/types.ts +++ b/packages/braintree-utils/src/types.ts @@ -1,3 +1,5 @@ +import { Address } from '@bigcommerce/checkout-sdk/payment-integration-api'; + export * from './braintree'; export * from './paypal'; export * from './visacheckout'; @@ -719,3 +721,64 @@ export interface BraintreeRedirectError { }; }; } + +export default interface BillingAddress extends Address { + id: string; + email?: string; +} + +export enum BraintreeSupportedCardBrands { + Visa = 'visa', + Mastercard = 'mastercard', + AmericanExpress = 'american-express', + DinersClub = 'diners-club', + Discover = 'discover', + Jcb = 'jcb', + UnionPay = 'union-pay', + Maestro = 'maestro', + Elo = 'elo', + Mir = 'mir', + Hiper = 'hiper', + Hipercard = 'hipercard', +} + +export interface BillingAddressState { + data?: BillingAddress; + errors: BillingAddressErrorsState; + statuses: BillingAddressStatusesState; +} + +export interface BillingAddressErrorsState { + loadError?: Error; + updateError?: Error; + continueAsGuestError?: Error; +} + +export interface BillingAddressStatusesState { + isLoading?: boolean; + isUpdating?: boolean; + isContinuingAsGuest?: boolean; +} + +export interface BraintreeRequestData { + data: { + creditCard: { + billingAddress?: { + countryCodeAlpha2: string; + locality: string; + countryName: string; + postalCode: string; + streetAddress: string; + }; + cardholderName: string; + cvv?: string; + expirationDate: string; + number: string; + options: { + validate: boolean; + }; + }; + }; + endpoint: string; + method: string; +} diff --git a/packages/braintree-utils/src/utils/index.ts b/packages/braintree-utils/src/utils/index.ts index d1af3d610a..2cd0672c6f 100644 --- a/packages/braintree-utils/src/utils/index.ts +++ b/packages/braintree-utils/src/utils/index.ts @@ -2,3 +2,6 @@ export { default as getFastlaneStyles } from './get-fastlane-styles'; export { default as isBraintreeAcceleratedCheckoutCustomer } from './is-braintree-accelerated-checkout-customer'; export { default as isBraintreeError } from './is-braintree-error'; export { default as isBraintreeFastlaneWindow } from './is-braintree-fastlane-window'; +export { default as isBraintreeHostedFormError } from './is-braintree-hosted-form-error'; +export { default as isBraintreeSupportedCardBrand } from './is-braintree-supported-card-brand'; +export { default as isBraintreePaymentRequest3DSError } from './is-braintree-payment-request-3ds-error'; diff --git a/packages/braintree-utils/src/utils/is-braintree-form-fields-map.spec.ts b/packages/braintree-utils/src/utils/is-braintree-form-fields-map.spec.ts new file mode 100644 index 0000000000..682acc9db5 --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-form-fields-map.spec.ts @@ -0,0 +1,15 @@ +import { isBraintreeStoredCardFieldsMap } from './is-braintree-form-fields-map'; +import { BraintreeFormFieldType, BraintreeStoredCardFieldsMap } from '../index'; + +describe('isBraintreeStoredCardFieldsMap', () => { + it('returns true if fields belong to stored card', () => { + const fields: BraintreeStoredCardFieldsMap = { + [BraintreeFormFieldType.CardCodeVerification]: { + instrumentId: 'instrumentId', + containerId: 'containerId', + }, + }; + + expect(isBraintreeStoredCardFieldsMap(fields)).toBe(true); + }); +}); diff --git a/packages/braintree-utils/src/utils/is-braintree-form-fields-map.ts b/packages/braintree-utils/src/utils/is-braintree-form-fields-map.ts new file mode 100644 index 0000000000..8679a5c278 --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-form-fields-map.ts @@ -0,0 +1,20 @@ +import { BraintreeFormFieldsMap, BraintreeStoredCardFieldsMap } from '../index'; + +export function isBraintreeFormFieldsMap( + fields: BraintreeFormFieldsMap | BraintreeStoredCardFieldsMap, +): fields is BraintreeFormFieldsMap { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return !!(fields as BraintreeFormFieldsMap).cardNumber; +} + +export function isBraintreeStoredCardFieldsMap( + fields: BraintreeFormFieldsMap | BraintreeStoredCardFieldsMap, +): fields is BraintreeStoredCardFieldsMap { + return !!( + Object.keys(fields).length > 0 && + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + ((fields as BraintreeStoredCardFieldsMap).cardCodeVerification || + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (fields as BraintreeStoredCardFieldsMap).cardNumberVerification) + ); +} diff --git a/packages/braintree-utils/src/utils/is-braintree-hosted-form-error.spec.ts b/packages/braintree-utils/src/utils/is-braintree-hosted-form-error.spec.ts new file mode 100644 index 0000000000..0ccf50a0f6 --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-hosted-form-error.spec.ts @@ -0,0 +1,15 @@ +import { isBraintreeHostedFormError } from '../index'; + +describe('isBraintreeHostedFormError', () => { + it('should return true error belongs to hosted form', () => { + const error = { + code: 'INVALID_DETAILS', + message: 'The "details.invalidFieldKeys" field is present but invalid.', + details: { + reason: 'Expected "invalidFieldKeys" to be undefined or a valid value, but got an invalid value instead.', + }, + }; + + expect(isBraintreeHostedFormError(error)).toBe(true); + }); +}); diff --git a/packages/braintree-utils/src/utils/is-braintree-hosted-form-error.ts b/packages/braintree-utils/src/utils/is-braintree-hosted-form-error.ts new file mode 100644 index 0000000000..8ec5ab01aa --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-hosted-form-error.ts @@ -0,0 +1,27 @@ +import isBraintreeError from './is-braintree-error'; +import { BraintreeHostedFormError } from '../types'; + +function isValidInvalidFieldKeys(invalidFieldKeys: unknown): invalidFieldKeys is string[] { + return ( + Array.isArray(invalidFieldKeys) && invalidFieldKeys.every((key) => typeof key === 'string') + ); +} + +export default function isBraintreeHostedFormError( + error: unknown, +): error is BraintreeHostedFormError { + if (!isBraintreeError(error)) { + return false; + } + + const { details } = error; + + return ( + details === undefined || + (typeof details === 'object' && + details !== null && + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (details as { invalidFieldKeys?: unknown }).invalidFieldKeys === undefined) || + isValidInvalidFieldKeys(details) + ); +} diff --git a/packages/braintree-utils/src/utils/is-braintree-payment-request-3d-error.spec.ts b/packages/braintree-utils/src/utils/is-braintree-payment-request-3d-error.spec.ts new file mode 100644 index 0000000000..ec9a49b4f5 --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-payment-request-3d-error.spec.ts @@ -0,0 +1,71 @@ +import isBraintreePaymentRequest3DSError, { + BraintreePaymentRequest3DSError, +} from './is-braintree-payment-request-3ds-error'; + +describe('isBraintreePaymentRequest3DSError', () => { + it('returns true for a valid BraintreePaymentRequest3DSError object', () => { + const error: BraintreePaymentRequest3DSError = { + name: 'SomeError', + body: { + status: '400', + three_ds_result: { + payer_auth_request: 'some-auth-request', + }, + errors: [{ code: 'three_d_secure_required' }], + }, + }; + + expect(isBraintreePaymentRequest3DSError(error)).toBe(true); + }); + + it('returns false if top-level fields are incorrect', () => { + const error = { + name: 123, + body: {}, + }; + + expect(isBraintreePaymentRequest3DSError(error)).toBe(false); + }); + + it('returns false if nested fields are missing', () => { + const error = { + name: 'ErrorName', + body: { + status: '400', + errors: [], + }, + }; + + expect(isBraintreePaymentRequest3DSError(error)).toBe(false); + }); + + it('returns false if payer_auth_request is not a string', () => { + const error = { + name: 'ErrorName', + body: { + status: '400', + three_ds_result: { + payer_auth_request: 123, + }, + errors: [], + }, + }; + + expect(isBraintreePaymentRequest3DSError(error)).toBe(false); + }); + + it('returns false if errors is not an array', () => { + const error = { + name: 'ErrorName', + body: { + status: '400', + three_ds_result: { + payer_auth_request: 'valid', + }, + errors: null, + }, + }; + + expect(isBraintreePaymentRequest3DSError(error)).toBe(false); + }); +}); diff --git a/packages/braintree-utils/src/utils/is-braintree-payment-request-3ds-error.ts b/packages/braintree-utils/src/utils/is-braintree-payment-request-3ds-error.ts new file mode 100644 index 0000000000..c0a786edcf --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-payment-request-3ds-error.ts @@ -0,0 +1,31 @@ +export interface BraintreePayment3DSRequestErrors { + code: string; +} + +export interface BraintreePaymentRequest3DSError { + name: string; + body: { + status: string; + three_ds_result: { + payer_auth_request: string; + }; + errors: BraintreePayment3DSRequestErrors[]; + }; +} + +export default function isBraintreePaymentRequest3DSError( + error: unknown, +): error is BraintreePaymentRequest3DSError { + if (typeof error !== 'object' || error === null) { + return false; + } + /* eslint-disable @typescript-eslint/consistent-type-assertions */ + return ( + 'name' in error && + 'body' in error && + 'status' in (error as BraintreePaymentRequest3DSError).body && + 'three_ds_result' in (error as BraintreePaymentRequest3DSError).body && + 'payer_auth_request' in (error as BraintreePaymentRequest3DSError).body.three_ds_result && + 'errors' in (error as BraintreePaymentRequest3DSError).body + ); +} diff --git a/packages/braintree-utils/src/utils/is-braintree-supported-card-brand.spec.ts b/packages/braintree-utils/src/utils/is-braintree-supported-card-brand.spec.ts new file mode 100644 index 0000000000..41feb0e3e0 --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-supported-card-brand.spec.ts @@ -0,0 +1,11 @@ +import { isBraintreeSupportedCardBrand } from './is-braintree-supported-card-brand'; + +describe('isBraintreeSupportedCardBrand', () => { + it('returns true if card brand is supported', () => { + const supportedCardBrand = 'mastercard'; + const unsupportedCardBrand = 'fakebank'; + + expect(isBraintreeSupportedCardBrand(supportedCardBrand)).toBe(true); + expect(isBraintreeSupportedCardBrand(unsupportedCardBrand)).toBe(false); + }); +}); diff --git a/packages/braintree-utils/src/utils/is-braintree-supported-card-brand.ts b/packages/braintree-utils/src/utils/is-braintree-supported-card-brand.ts new file mode 100644 index 0000000000..08638fee42 --- /dev/null +++ b/packages/braintree-utils/src/utils/is-braintree-supported-card-brand.ts @@ -0,0 +1,12 @@ +import { BraintreeSupportedCardBrands } from '../types'; + +export const isBraintreeSupportedCardBrand = ( + cardBrand: string, +): cardBrand is BraintreeSupportedCardBrands => { + const supportedCardBrands = Object.values(BraintreeSupportedCardBrands); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return supportedCardBrands.includes(cardBrand as BraintreeSupportedCardBrands); +}; + +export default isBraintreeSupportedCardBrand; diff --git a/packages/braintree-utils/src/utils/is-credit-card-instrument-like.ts b/packages/braintree-utils/src/utils/is-credit-card-instrument-like.ts new file mode 100644 index 0000000000..115019ab68 --- /dev/null +++ b/packages/braintree-utils/src/utils/is-credit-card-instrument-like.ts @@ -0,0 +1,15 @@ +import { CreditCardInstrument } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +export default function isCreditCardInstrumentLike( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instrument: any, +): instrument is CreditCardInstrument { + return ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof instrument.ccExpiry === 'object' && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof instrument.ccNumber === 'string' && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof instrument.ccName === 'string' + ); +} diff --git a/packages/payment-integration-api/src/index.ts b/packages/payment-integration-api/src/index.ts index dfe88a10f6..b841113ab6 100644 --- a/packages/payment-integration-api/src/index.ts +++ b/packages/payment-integration-api/src/index.ts @@ -130,6 +130,7 @@ export { WithSepaInstrument, WithIdealInstrument, WithPayByBankInstrument, + BraintreePaymentInstrument, BlueSnapDirectEcpPayload, BlueSnapDirectSepaPayload, IdealPayload, diff --git a/packages/payment-integration-api/src/payment/index.ts b/packages/payment-integration-api/src/payment/index.ts index 5610929dce..7379848d8d 100644 --- a/packages/payment-integration-api/src/payment/index.ts +++ b/packages/payment-integration-api/src/payment/index.ts @@ -2,6 +2,7 @@ export { default as InitializeOffsitePaymentConfig } from './initialize-offsite- export { default as PaymentInstrument, AccountInstrument, + BraintreePaymentInstrument, CardInstrument, UntrustedShippingCardVerificationType, PayPalInstrument, diff --git a/packages/payment-integration-api/src/payment/instrument.ts b/packages/payment-integration-api/src/payment/instrument.ts index b6762e0a15..00f346dbaa 100644 --- a/packages/payment-integration-api/src/payment/instrument.ts +++ b/packages/payment-integration-api/src/payment/instrument.ts @@ -1,5 +1,9 @@ +import { HostedInstrument, NonceInstrument } from './payment'; + type PaymentInstrument = CardInstrument | AccountInstrument; +export type BraintreePaymentInstrument = HostedInstrument | NonceInstrument; + export default PaymentInstrument; interface BaseInstrument {