diff --git a/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy-initialize-options.ts b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy-initialize-options.ts new file mode 100644 index 0000000000..1b0d0b5366 --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy-initialize-options.ts @@ -0,0 +1,103 @@ +import { + BraintreeError, + BraintreeFormOptions, +} from '@bigcommerce/checkout-sdk/braintree-utils'; +import { StandardError } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { RequestOptions } from '@bigcommerce/request-sender'; +import { BraintreeThreeDSecureOptions } from '@bigcommerce/checkout-sdk/braintree-integration'; + +export interface PaymentRequestOptions extends RequestOptions { + /** + * The identifier of the payment method. + */ + methodId: string; + + /** + * The identifier of the payment provider providing the payment method. This + * option is only required if the provider offers multiple payment options. + * i.e.: Adyen and Klarna. + */ + gatewayId?: string; +} + +interface BraintreePaymentInitializeOptions { + /** + * 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; + + /** + * 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[]; +} + +/** + * A set of options that are required to initialize the payment step of the + * current checkout flow. + */ +export interface WithBraintreeCreditCardPaymentStrategyInitializeOptions extends PaymentRequestOptions { + + /** + * The options that are required to initialize the Braintree payment method. + * They can be omitted unless you need to support Braintree. + */ + braintree?: BraintreePaymentInitializeOptions; +} 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..85e52a5e83 --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.spec.ts @@ -0,0 +1,459 @@ +import BraintreeCreditCardPaymentStrategy from './braintree-credit-card-payment-strategy'; +import { + MissingDataError, OrderFinalizationNotRequiredError, + PaymentIntegrationService, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { + getCart, + getConfig, + getOrderRequestBody, + PaymentIntegrationServiceMock, +} from '@bigcommerce/checkout-sdk/payment-integrations-test-utils'; +import { + BraintreeFastlane, + BraintreeIntegrationService, + BraintreePaymentProcessor, + BraintreeScriptLoader, BraintreeSDKVersionManager, + getBraintree, + getFastlaneMock, + getHostedFieldsMock, +} from '@bigcommerce/checkout-sdk/braintree-utils'; +import { + BraintreeHostedForm, + BraintreePaymentInitializeOptions, +} from '@bigcommerce/checkout-sdk/braintree-integration'; +import { getScriptLoader } from '@bigcommerce/script-loader'; +import { + getBillingAddress, getModuleCreatorMock, + getThreeDSecureMock, + getThreeDSecureOptionsMock, + getTokenizeResponseBody, +} from '../mocks/braintree.mock'; +import { merge } from 'lodash'; + +describe('BraintreeCreditCardPaymentStrategy', () => { + let braintreeCreditCardPaymentStrategy: BraintreeCreditCardPaymentStrategy; + let paymentIntegrationService: PaymentIntegrationService; + let braintreePaymentProcessor: BraintreePaymentProcessor; + let braintreeIntegrationService: BraintreeIntegrationService; + let braintreeScriptLoader: BraintreeScriptLoader; + let braintreeHostedForm: BraintreeHostedForm; + let paymentMethod: any; // TODO: FIX + let braintreeFastlaneMock: BraintreeFastlane; + let braintreeSDKVersionManager: BraintreeSDKVersionManager; + + beforeEach(() => { + const methodId = 'braintree'; + paymentMethod = { + ...getBraintree(), + id: methodId, + initializationData: { + isAcceleratedCheckoutEnabled: true, + shouldRunAcceleratedCheckout: true, + }, + }; + braintreeSDKVersionManager = new BraintreeSDKVersionManager(paymentIntegrationService); + braintreeScriptLoader = new BraintreeScriptLoader( + getScriptLoader(), + window, + braintreeSDKVersionManager, + ); + braintreeFastlaneMock = getFastlaneMock(); + paymentIntegrationService = new PaymentIntegrationServiceMock(); + braintreeIntegrationService = new BraintreeIntegrationService( + braintreeScriptLoader, + window, + ); + braintreeHostedForm = new BraintreeHostedForm(braintreeIntegrationService, braintreeScriptLoader); + braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeIntegrationService, + braintreeHostedForm, + ); + braintreeCreditCardPaymentStrategy = new BraintreeCreditCardPaymentStrategy( + paymentIntegrationService, + braintreePaymentProcessor, + braintreeIntegrationService, + ); + jest.spyOn(paymentIntegrationService.getState(), 'getPaymentMethodOrThrow').mockReturnValue( + paymentMethod, + ); + jest.spyOn(braintreeIntegrationService, 'getSessionId').mockResolvedValue('sessionId'); + jest.spyOn(braintreeIntegrationService, 'getBraintreeFastlane').mockResolvedValue(braintreeFastlaneMock); + jest.spyOn(braintreePaymentProcessor, 'initializeHostedForm'); + jest.spyOn(braintreePaymentProcessor, 'initialize'); + jest.spyOn(braintreeIntegrationService, 'initialize'); + jest.spyOn(braintreePaymentProcessor, 'isInitializedHostedForm'); + braintreeScriptLoader.loadClient = jest.fn(); + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue({ + create: jest.fn(), + }); + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn(), + getVersion: jest.fn(), + }); + jest.spyOn(braintreeHostedForm, 'initialize'); + jest.spyOn(paymentIntegrationService.getState(), 'getPaymentMethodOrThrow').mockReturnValue( + paymentMethod, + ); + jest.spyOn(braintreeIntegrationService, 'teardown'); + jest.spyOn(braintreePaymentProcessor, 'deinitializeHostedForm'); + jest.spyOn(paymentIntegrationService, 'submitPayment'); + }); + + 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 = { braintree: {}, methodId: paymentMethod.id }; + + await braintreeCreditCardPaymentStrategy.initialize(options); + + expect(braintreeIntegrationService.initialize).toHaveBeenCalledWith( + paymentMethod.clientToken, + ); + expect(braintreePaymentProcessor.initializeHostedForm).not.toHaveBeenCalled(); + expect(braintreePaymentProcessor.isInitializedHostedForm).not.toHaveBeenCalled(); + 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(braintreePaymentProcessor, 'isInitializedHostedForm').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(braintreePaymentProcessor.initializeHostedForm).toHaveBeenCalledWith( + options.braintree.form, + options.braintree.unsupportedCardBrands, + ); + expect(braintreePaymentProcessor.isInitializedHostedForm).toHaveBeenCalled(); + 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 = { + methodId: paymentMethod.id, + braintree: { + form: { + fields: { + cardName: { containerId: 'cardName' }, + cardNumber: { containerId: 'cardNumber' }, + cardExpiry: { containerId: 'cardExpiry' }, + }, + }, + }, + }; + + 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(braintreePaymentProcessor.deinitializeHostedForm).toHaveBeenCalled(); + }); + + it('resets hosted form initialization state on strategy deinitialization', async () => { + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + getVersion: jest.fn(), + }); + jest.spyOn(braintreePaymentProcessor, 'tokenizeHostedForm'); + jest.spyOn(braintreePaymentProcessor, 'tokenizeCard'); + braintreePaymentProcessor.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' }, + }, + }, + }, + }); + + await braintreeCreditCardPaymentStrategy.deinitialize(); + await braintreeCreditCardPaymentStrategy.execute(getOrderRequestBody()); + + expect(braintreePaymentProcessor.tokenizeHostedForm).not.toHaveBeenCalled(); + expect(braintreePaymentProcessor.tokenizeCard).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()', () => { + describe('common execution behaviour', () => { + it('calls submit order with the order request information', async () => { + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + getVersion: jest.fn(), + }); + + 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()), + getVersion: jest.fn(), + }); + + 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(braintreePaymentProcessor, 'tokenizeCard'); + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + getVersion: jest.fn(), + }); + 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(braintreePaymentProcessor.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(braintreePaymentProcessor, 'verifyCard').mockResolvedValue({ + nonce: 'demo_nonce', + }); + jest.spyOn(braintreeIntegrationService, 'getClient').mockResolvedValue({ + request: jest.fn().mockResolvedValue(getTokenizeResponseBody()), + getVersion: jest.fn(), + }); + const options3ds = { + methodId: paymentMethod.id, + braintree: { + threeDSecure: getThreeDSecureOptionsMock(), + } + }; + + 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(braintreePaymentProcessor.verifyCard).toHaveBeenCalledWith( + getOrderRequestBody().payment, + getBillingAddress(), + 190, + ); + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith(expected); + }); + }); + }); + + describe('hosted form behaviour', () => { + let initializeOptions: BraintreePaymentInitializeOptions; + + beforeEach(() => { + initializeOptions = { + form: { + fields: { + cardName: { containerId: 'cardName' }, + cardNumber: { containerId: 'cardNumber' }, + cardExpiry: { containerId: 'cardExpiry' }, + }, + }, + }; + + paymentMethod.config.isHostedFormEnabled = true; + }); + + it('tokenizes payment data through hosted form and submits it', async () => { + jest.spyOn(braintreePaymentProcessor, 'validateHostedForm'); + const hostedFieldsMock = getHostedFieldsMock(); + const hostedFieldsCreatorMock = getModuleCreatorMock(hostedFieldsMock); + jest.spyOn(braintreeScriptLoader, 'loadHostedFields').mockResolvedValue( + //@ts-ignore + hostedFieldsCreatorMock, + ); + jest.spyOn(braintreeHostedForm, 'createHostedFields').mockResolvedValue({ + getState: jest.fn().mockResolvedValue({ + cards: [{ + type: 'card', + niceType: 'card', + code: { + name: 'card', + size: 2 }, + }], + emittedBy: '', + fields: { + number: { + container: 'div', + isFocused: true, + isEmpty: false, + isPotentiallyValid: true, + isValid: true, + }, + }, + }), + teardown: jest.fn(), + tokenize: jest.fn(), + on: jest.fn(), + }) + await braintreeCreditCardPaymentStrategy.initialize({ + methodId: paymentMethod.id, + braintree: initializeOptions, + }); + + await braintreeCreditCardPaymentStrategy.execute(getOrderRequestBody()); + + expect(braintreePaymentProcessor.tokenizeHostedForm).toHaveBeenCalledWith( + getBillingAddress(), + ); + + expect(paymentIntegrationService.submitPayment).toHaveBeenCalledWith({ + ...getOrderRequestBody().payment, + paymentData: { + deviceSessionId: 'my_session_id', + nonce: 'my_tokenized_card_with_hosted_form', + shouldSaveInstrument: false, + shouldSetAsDefaultInstrument: false, + }, + }); + }); + }); + }); +}); 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..d78e1f4621 --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/braintree-credit-card-payment-strategy.ts @@ -0,0 +1,294 @@ +import { some } from 'lodash'; + +import { + BraintreeIntegrationService, + isBraintreeAcceleratedCheckoutCustomer, + BraintreePaymentProcessor, +} from '@bigcommerce/checkout-sdk/braintree-utils'; +import { + Address, + isHostedInstrumentLike, + isVaultedInstrument, + MissingDataError, + MissingDataErrorType, + OrderFinalizationNotRequiredError, + OrderPaymentRequestBody, + OrderRequestBody, + PaymentArgumentInvalidError, + PaymentInitializeOptions, + PaymentInstrument, + PaymentInstrumentMeta, + PaymentIntegrationService, + PaymentMethod, + PaymentMethodFailedError, + PaymentStrategy, RequestError, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { WithBraintreeCreditCardPaymentStrategyInitializeOptions } from './braintree-credit-card-payment-strategy-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 braintreePaymentProcessor: BraintreePaymentProcessor, + private braintreeIntegrationService: BraintreeIntegrationService, + ) {} + + async initialize( + options: PaymentInitializeOptions & WithBraintreeCreditCardPaymentStrategyInitializeOptions, + ): Promise { + 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.braintreePaymentProcessor.initialize(clientToken, braintree); + + if (this.isHostedPaymentFormEnabled(methodId, gatewayId) && braintree?.form) { + await this.braintreePaymentProcessor.initializeHostedForm( + braintree.form, + braintree.unsupportedCardBrands, + ); + this.isHostedFormInitialized = + this.braintreePaymentProcessor.isInitializedHostedForm(); + } + + this.is3dsEnabled = this.paymentMethod.config.is3dsEnabled; + this.deviceSessionId = await this.braintreeIntegrationService.getSessionId(); + + // TODO: remove this part when BT AXO A/B testing will be finished + if (this.shouldInitializeBraintreeFastlane()) { + await this.initializeBraintreeFastlaneOrThrow(methodId); + } + } catch (error) { + return this.handleError(error); + } + } + + async execute(orderRequest: OrderRequestBody): Promise { + const { payment } = orderRequest; + const state = this.paymentIntegrationService.getState(); + + if (!payment) { + throw new PaymentArgumentInvalidError(['payment']); + } + + if (this.isHostedFormInitialized) { + this.braintreePaymentProcessor.validateHostedForm(); + } + + await this.paymentIntegrationService.submitOrder(); + + const billingAddress = state.getBillingAddressOrThrow(); + const orderAmount = state.getOrderOrThrow().orderAmount; + const paymentPayload = this.isHostedFormInitialized + ? await this.prepareHostedPaymentData(payment, billingAddress, orderAmount) + : await this.preparePaymentData(payment, billingAddress, orderAmount); + + try { + await this.paymentIntegrationService.submitPayment({ + ...payment, + paymentData: paymentPayload, + }); + } catch (error) { + return this.processAdditionalAction(error, payment, orderAmount); + } + } + + finalize(): Promise { + return Promise.reject(new OrderFinalizationNotRequiredError()); + } + + async deinitialize(): Promise { + this.isHostedFormInitialized = false; + + await this.braintreeIntegrationService.teardown(); + await this.braintreePaymentProcessor.deinitializeHostedForm(); + + 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.braintreePaymentProcessor.verifyCard(payment, billingAddress, orderAmount) + : await this.braintreePaymentProcessor.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.braintreePaymentProcessor.tokenizeHostedFormForStoredCardVerification(); + + return { + ...commonPaymentData, + ...paymentData, + nonce, + }; + } + + const { shouldSaveInstrument = false, shouldSetAsDefaultInstrument = false } = + isHostedInstrumentLike(paymentData) ? paymentData : {}; + + const { nonce } = this.shouldPerform3DSVerification(payment) + ? await this.braintreePaymentProcessor.verifyCardWithHostedForm( + billingAddress, + orderAmount, + ) + : await this.braintreePaymentProcessor.tokenizeHostedForm(billingAddress); + + return { + ...commonPaymentData, + shouldSaveInstrument, + shouldSetAsDefaultInstrument, + nonce, + }; + } + + private async processAdditionalAction( + error: unknown, + payment: OrderPaymentRequestBody, + orderAmount: number, + ): Promise { + const state = this.paymentIntegrationService.getState(); + + if ( + !(error instanceof RequestError) || + !some(error.body.errors, { code: 'three_d_secure_required' }) + ) { + return this.handleError(error); + } + + try { + const { payer_auth_request: storedCreditCardNonce } = error.body.three_ds_result || {}; + const { paymentData } = payment; + + if (!paymentData || !isVaultedInstrument(paymentData)) { + throw new PaymentArgumentInvalidError(['instrumentId']); + } + + const instrument = state.getCardInstrumentOrThrow(paymentData.instrumentId); + const { nonce } = await this.braintreePaymentProcessor.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 { + const state = this.paymentIntegrationService.getState(); + + if (!methodId) { + return false; + } + + 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 this part when BT AXO A/B testing will be finished + private shouldInitializeBraintreeFastlane() { + const state = this.paymentIntegrationService.getState(); + const paymentProviderCustomer = state.getPaymentProviderCustomerOrThrow(); + const braintreePaymentProviderCustomer = isBraintreeAcceleratedCheckoutCustomer( + paymentProviderCustomer, + ) + ? paymentProviderCustomer + : {}; + const isAcceleratedCheckoutEnabled = + this.paymentMethod?.initializationData.isAcceleratedCheckoutEnabled; + + return ( + isAcceleratedCheckoutEnabled && !braintreePaymentProviderCustomer?.authenticationState + ); + } + + // TODO: remove this part when BT AXO A/B testing will be 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..29f08f1360 --- /dev/null +++ b/packages/braintree-integration/src/braintree-credit-card/create-braintree-credit-card-payment-strategy.ts @@ -0,0 +1,52 @@ +import { getScriptLoader } from '@bigcommerce/script-loader'; + +import { + BraintreeHostWindow, + BraintreeIntegrationService, + BraintreePaymentProcessor, + 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 '@bigcommerce/checkout-sdk/braintree-integration'; + +const createBraintreeCreditCardPaymentStrategy: PaymentStrategyFactory< + BraintreeCreditCardPaymentStrategy +> = (paymentIntegrationService) => { + const braintreeHostWindow: BraintreeHostWindow = window; + const braintreeSDKVersionManager = new BraintreeSDKVersionManager(paymentIntegrationService); + const braintreeIntegrationService = new BraintreeIntegrationService( + new BraintreeScriptLoader( + getScriptLoader(), + braintreeHostWindow, + braintreeSDKVersionManager, + ), + braintreeHostWindow, + ); + const braintreeHostedForm = new BraintreeHostedForm( + braintreeIntegrationService, + new BraintreeScriptLoader( + getScriptLoader(), + braintreeHostWindow, + braintreeSDKVersionManager, + ), + ); + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeIntegrationService, + braintreeHostedForm, + ); + + return new BraintreeCreditCardPaymentStrategy( + paymentIntegrationService, + braintreePaymentProcessor, + braintreeIntegrationService + ); +}; + +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..ffe6023112 --- /dev/null +++ b/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.spec.ts @@ -0,0 +1,493 @@ +import { EventEmitter } from 'events'; +import { BraintreeHostedFields } from '@bigcommerce/checkout-sdk/braintree-utils'; +import { BraintreeFormOptions } from '../braintree-payment-options'; +import { BraintreeHostedForm } from '@bigcommerce/checkout-sdk/braintree-integration'; +import { NotInitializedError, PaymentInvalidFormError } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { getBillingAddress } from '@bigcommerce/checkout-sdk/braintree-utils'; + +describe('BraintreeHostedForm', () => { + let braintreeSdkCreator: Pick; + let cardFields: Pick; + let cardFieldsEventEmitter: EventEmitter; + let containers: HTMLElement[]; + let formOptions: BraintreeFormOptions; + let subject: BraintreeHostedForm; + + 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); + }), + // TODO: remove ts-ignore and update test with related type (PAYPAL-4383) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + tokenize: jest.fn(() => Promise.resolve({ nonce: 'foobar_nonce' })), + teardown: jest.fn(), + }; + + braintreeSdkCreator = { + createHostedFields: jest.fn(() => Promise.resolve(cardFields as BraintreeHostedFields)), + }; + + 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', + }, + }, + }; + + subject = new BraintreeHostedForm(braintreeSdkCreator as BraintreeSDKCreator); + + containers = [ + appendContainer('cardCode'), + appendContainer('cardName'), + appendContainer('cardNumber'), + appendContainer('cardExpiry'), + ]; + }); + + afterEach(() => { + containers.forEach((container) => { + container.parentElement?.removeChild(container); + }); + }); + + describe('#initialize', () => { + it('creates and configures hosted fields', async () => { + await subject.initialize(formOptions, unsupportedCardBrands); + + expect(braintreeSdkCreator.createHostedFields).toHaveBeenCalledWith({ + fields: { + cvv: { + container: '#cardCode', + placeholder: 'Card code', + }, + expirationDate: { + container: '#cardExpiry', + placeholder: 'Card expiry', + }, + number: { + container: '#cardNumber', + placeholder: 'Card number', + supportedCardBrands: { + 'american-express': false, + maestro: false, + }, + }, + cardholderName: { + container: '#cardName', + placeholder: 'Card name', + }, + }, + styles: { + input: { + color: '#000', + }, + '.invalid': { + color: '#f00', + 'font-weight': 'bold', + }, + ':focus': { + color: '#00f', + }, + }, + }); + }); + + it('creates and configures hosted fields for stored card verification', async () => { + 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', + }, + }, + styles: { + default: { + color: '#000', + }, + error: { + color: '#f00', + fontWeight: 'bold', + }, + focus: { + color: '#00f', + }, + }, + }); + + expect(braintreeSdkCreator.createHostedFields).toHaveBeenCalledWith({ + 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', + }, + }, + }); + }); + }); + + describe('#isInitialized', () => { + it('returns true if hosted form is initialized', async () => { + await subject.initialize(formOptions); + + expect(subject.isInitialized()).toBe(true); + }); + + it('returns false when no fields specified in form options', async () => { + await subject.initialize({ fields: {} }); + + expect(subject.isInitialized()).toBe(false); + }); + + it('changes hosted form initialization state', async () => { + await subject.initialize(formOptions); + + expect(subject.isInitialized()).toBe(true); + + await subject.deinitialize(); + + expect(subject.isInitialized()).toBe(false); + }); + }); + + describe('#deinitialize', () => { + it('calls hosted form fields teardown on deinitialize', async () => { + await subject.initialize(formOptions); + await subject.deinitialize(); + + expect(cardFields.teardown).toHaveBeenCalled(); + }); + + it('do not call teardown if fields are not initialized', async () => { + await subject.initialize({ + ...formOptions, + fields: {}, + }); + await subject.deinitialize(); + + expect(cardFields.teardown).not.toHaveBeenCalled(); + }); + }); + + describe('#tokenize', () => { + it('tokenizes data through hosted fields', async () => { + await subject.initialize(formOptions); + + const billingAddress = getBillingAddress(); + + await subject.tokenize(billingAddress); + + expect(cardFields.tokenize).toHaveBeenCalledWith({ + billingAddress: { + countryName: billingAddress.country, + postalCode: billingAddress.postalCode, + streetAddress: billingAddress.address1, + }, + }); + }); + + it('returns invalid form error when tokenizing with invalid form data', async () => { + await subject.initialize(formOptions); + + jest.spyOn(cardFields, 'tokenize').mockRejectedValue({ + name: 'BraintreeError', + code: 'HOSTED_FIELDS_FIELDS_EMPTY', + }); + + try { + await subject.tokenize(getBillingAddress()); + } catch (error) { + expect(error).toBeInstanceOf(PaymentInvalidFormError); + } + }); + + it('throws error if trying to tokenize before initialization', async () => { + try { + await subject.tokenize(getBillingAddress()); + } catch (error) { + expect(error).toBeInstanceOf(NotInitializedError); + } + }); + }); + + describe('#tokenizeForStoredCardVerification', () => { + it('tokenizes data through hosted fields for stored card verification', async () => { + await subject.initialize(formOptions); + + await subject.tokenizeForStoredCardVerification(); + + expect(cardFields.tokenize).toHaveBeenCalled(); + }); + + it('returns invalid form error when tokenizing store credit card with invalid form data', async () => { + await subject.initialize(formOptions); + + jest.spyOn(cardFields, 'tokenize').mockRejectedValue({ + name: 'BraintreeError', + code: 'HOSTED_FIELDS_FIELDS_EMPTY', + }); + + try { + await subject.tokenizeForStoredCardVerification(); + } catch (error) { + expect(error).toBeInstanceOf(PaymentInvalidFormError); + } + }); + + it('throws error if trying to tokenize store credit card before initialization', async () => { + try { + await subject.tokenizeForStoredCardVerification(); + } catch (error) { + expect(error).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 () => { + 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, + }); + }); + + it('notifies when field receives focus', () => { + cardFieldsEventEmitter.emit('focus', { emittedBy: 'cvv' }); + + expect(handleFocus).toHaveBeenCalledWith({ fieldType: 'cardCode' }); + }); + + it('notifies when field loses focus', () => { + cardFieldsEventEmitter.emit('blur', { emittedBy: 'cvv' }); + + expect(handleBlur).toHaveBeenCalledWith({ fieldType: 'cardCode', errors: {} }); + }); + + describe('when event fields are provided', () => { + it('notifies with proper 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 when input receives submit event', () => { + cardFieldsEventEmitter.emit('inputSubmitRequest', { emittedBy: 'cvv' }); + + expect(handleEnter).toHaveBeenCalledWith({ fieldType: 'cardCode' }); + }); + + it('notifies when card number changes', () => { + cardFieldsEventEmitter.emit('cardTypeChange', { cards: [{ type: 'visa' }] }); + + expect(handleCardTypeChange).toHaveBeenCalledWith({ cardType: 'visa' }); + }); + + it('notifies when card number changes and type is master-card', () => { + cardFieldsEventEmitter.emit('cardTypeChange', { cards: [{ type: 'master-card' }] }); + + expect(handleCardTypeChange).toHaveBeenCalledWith({ cardType: 'mastercard' }); + }); + + it('notifies when card number changes and type of card is not yet known', () => { + cardFieldsEventEmitter.emit('cardTypeChange', { + cards: [{ type: 'visa' }, { type: 'master-card' }], + }); + + expect(handleCardTypeChange).toHaveBeenCalledWith({ cardType: undefined }); + }); + + it('notifies when there are 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: 'invalid_card_code', + }, + ], + cardNumber: [ + { + fieldType: 'cardNumber', + message: 'Invalid card number', + type: 'invalid_card_number', + }, + ], + cardExpiry: [ + { + fieldType: 'cardExpiry', + message: 'Invalid card expiry', + type: 'invalid_card_expiry', + }, + ], + }, + isValid: false, + }); + }); + + it('notifies when there are no more validation errors', () => { + 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 with invalid form data', async () => { + jest.spyOn(cardFields, 'tokenize').mockRejectedValue({ + name: 'BraintreeError', + code: 'HOSTED_FIELDS_FIELDS_EMPTY', + }); + + try { + await subject.tokenize(getBillingAddress()); + } catch (error) { + expect(handleValidate).toHaveBeenCalledWith({ + errors: { + cardCode: [ + { + fieldType: 'cardCode', + message: 'CVV is required', + type: 'required', + }, + ], + cardNumber: [ + { + fieldType: 'cardNumber', + message: 'Credit card number is required', + type: 'required', + }, + ], + cardExpiry: [ + { + fieldType: 'cardExpiry', + message: 'Expiration date is required', + type: 'required', + }, + ], + cardName: [ + { + fieldType: 'cardName', + message: 'Full name is required', + type: 'required', + }, + ], + }, + isValid: false, + }); + } + }); + + it('notifies when tokenizing with valid form data', 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..f5a6761a13 --- /dev/null +++ b/packages/braintree-integration/src/braintree-hosted-form/braintree-hosted-form.ts @@ -0,0 +1,550 @@ +import { Dictionary, isEmpty, isNil, omitBy } from 'lodash'; +import { + BraintreeBillingAddressRequestData, + BraintreeFormErrorDataKeys, + BraintreeFormErrorsData, + BraintreeFormOptions, + BraintreeHostedFields, + BraintreeHostedFieldsCreatorConfig, + BraintreeHostedFieldsState, + BraintreeHostedFormError, + BraintreeIntegrationService, + BraintreeScriptLoader, + TokenizationPayload, +} from '@bigcommerce/checkout-sdk/braintree-utils'; +import { + Address, + NotInitializedError, + NotInitializedErrorType, + PaymentInvalidFormError, + PaymentInvalidFormErrorDetails, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { + BraintreeFormFieldsMap, + BraintreeFormFieldStyles, + BraintreeFormFieldStylesMap, + BraintreeFormFieldType, + BraintreeFormFieldValidateErrorData, + BraintreeFormFieldValidateEventData, + BraintreeStoredCardFieldsMap, +} from '../braintree-payment-options'; +import { isBraintreeHostedFormError } from '../../../braintree-utils/src/is-braintree-hosted-form-error'; +import { isBraintreeFormFieldsMap } from '../../../braintree-utils/src/is-braintree-form-fields-map'; +import { isBraintreeSupportedCardBrand } from '../../../braintree-utils/src/is-braintree-supported-card-brand'; + +enum BraintreeHostedFormType { + CreditCard, + StoredCardVerification, +} + +export default class BraintreeHostedForm { + private cardFields?: BraintreeHostedFields; + private formOptions?: BraintreeFormOptions; + private type?: BraintreeHostedFormType; + private isInitializedHostedForm = false; + + constructor( + private braintreeIntegrationService: BraintreeIntegrationService, + private braintreeScriptLoader: BraintreeScriptLoader, + ) {} + + async initialize( + options: BraintreeFormOptions, + unsupportedCardBrands?: string[], + ): Promise { + 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() { + if (!this.cardFields) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + const braintreeHostedFormState = this.cardFields.getState(); + console.log('CARD FIELDS', this.cardFields.getState()); + + if (!this.isValidForm(braintreeHostedFormState)) { + this.handleValidityChange(braintreeHostedFormState); + + const errors = this.mapValidationErrors(braintreeHostedFormState.fields); + + throw new PaymentInvalidFormError(errors as PaymentInvalidFormErrorDetails); + } + } + + async tokenize(billingAddress: Address): Promise { + if (!this.cardFields) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + try { + const tokenizationPayload = await this.cardFields.tokenize( + omitBy( + { + billingAddress: billingAddress && this.mapBillingAddress(billingAddress), + }, + isNil, + ), + ); + + this.formOptions?.onValidate?.({ + isValid: true, + errors: {}, + }); + + return { + nonce: tokenizationPayload.nonce, + bin: tokenizationPayload.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 createHostedFields( + options: Pick, + ): Promise { + const client = await this.braintreeIntegrationService.getClient(); + const hostedFields = await this.braintreeScriptLoader.loadHostedFields(); + + return hostedFields.create({ ...options, client }); + } + + async tokenizeForStoredCardVerification(): Promise { + if (!this.cardFields) { + throw new NotInitializedError(NotInitializedErrorType.PaymentNotInitialized); + } + + try { + const tokenizationPayload = await this.cardFields.tokenize(); + + this.formOptions?.onValidate?.({ + isValid: true, + errors: {}, + }); + + return { + nonce: tokenizationPayload.nonce, + bin: tokenizationPayload.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; + } + } + + 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> = {}; + + if (unsupportedCardBrands) { + for (const cardBrand of unsupportedCardBrands) { + 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) { + 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 cvvValidation = { + [this.mapFieldType('cvv')]: [this.createRequiredError(this.mapFieldType('cvv'))], + }; + + const expirationDateValidation = { + [this.mapFieldType('expirationDate')]: [ + this.createRequiredError(this.mapFieldType('expirationDate')), + ], + }; + + const cardNumberValidation = { + [this.mapFieldType('number')]: [ + this.createRequiredError(this.mapFieldType('number')), + ], + }; + + const cardNameValidation = { + [this.mapFieldType('cardholderName')]: [ + this.createRequiredError(this.mapFieldType('cardholderName')), + ], + }; + + return isStoredCard + ? cvvValidation + : { + ...cvvValidation, + ...expirationDateValidation, + ...cardNumberValidation, + ...cardNameValidation, + }; + } + + return error.details?.invalidFieldKeys?.reduce( + (result, fieldKey) => ({ + ...result, + [this.mapFieldType(fieldKey)]: [ + this.createInvalidError(this.mapFieldType(fieldKey)), + ], + }), + {}, + ); + } + + private createRequiredError( + fieldType: BraintreeFormFieldType, + ): BraintreeFormFieldValidateErrorData { + switch (fieldType) { + case BraintreeFormFieldType.CardCodeVerification: + case BraintreeFormFieldType.CardCode: + return { + fieldType, + message: 'CVV is required', + type: 'required', + }; + + case BraintreeFormFieldType.CardNumberVerification: + case BraintreeFormFieldType.CardNumber: + return { + fieldType, + message: 'Credit card number is required', + type: 'required', + }; + + case BraintreeFormFieldType.CardExpiry: + return { + fieldType, + message: 'Expiration date is required', + type: 'required', + }; + + case BraintreeFormFieldType.CardName: + return { + fieldType, + message: 'Full name is required', + type: 'required', + }; + + default: + return { + fieldType, + message: 'Field is required', + type: 'required', + }; + } + } + + private createInvalidError( + fieldType: BraintreeFormFieldType, + ): BraintreeFormFieldValidateErrorData { + switch (fieldType) { + case BraintreeFormFieldType.CardCodeVerification: + return { + fieldType, + message: 'Invalid card code', + type: 'invalid_card_code', + }; + + case BraintreeFormFieldType.CardNumberVerification: + return { + fieldType, + message: 'Invalid card number', + type: 'invalid_card_number', + }; + + case BraintreeFormFieldType.CardCode: + return { + fieldType, + message: 'Invalid card code', + type: 'invalid_card_code', + }; + + case BraintreeFormFieldType.CardExpiry: + return { + fieldType, + message: 'Invalid card expiry', + type: 'invalid_card_expiry', + }; + + case BraintreeFormFieldType.CardNumber: + return { + fieldType, + message: 'Invalid card number', + type: 'invalid_card_number', + }; + + case BraintreeFormFieldType.CardName: + return { + fieldType, + message: 'Invalid card name', + type: 'invalid_card_name', + }; + + default: + return { + fieldType, + message: 'Invalid field', + type: 'invalid', + }; + } + } + + private handleBlur: (event: BraintreeHostedFieldsState) => void = (event) => { + this.formOptions?.onBlur?.({ + fieldType: this.mapFieldType(event.emittedBy), + errors: this.mapErrors(event.fields), + }); + }; + + private handleFocus: (event: BraintreeHostedFieldsState) => void = (event) => { + this.formOptions?.onFocus?.({ + fieldType: this.mapFieldType(event.emittedBy), + }); + }; + + private handleCardTypeChange: (event: BraintreeHostedFieldsState) => void = (event) => { + this.formOptions?.onCardTypeChange?.({ + cardType: + event.cards.length === 1 + ? event.cards[0].type.replace(/^master\-card$/, 'mastercard',) /* eslint-disable-line */ + : undefined, + }); + }; + + private handleInputSubmitRequest: (event: BraintreeHostedFieldsState) => void = (event) => { + this.formOptions?.onEnter?.({ + fieldType: this.mapFieldType(event.emittedBy), + }); + }; + + private handleValidityChange: (event: BraintreeHostedFieldsState) => void = (event) => { + this.formOptions?.onValidate?.({ + isValid: this.isValidForm(event), + errors: this.mapValidationErrors(event.fields), + }); + }; + + private isValidForm(event: BraintreeHostedFieldsState): boolean { + console.log('isValidForm', event.fields); + return ( + Object.keys(event.fields) as Array + ).every((key) => event.fields[key]?.isValid); + } + + private isValidParam( + formErrorDataKey: string, + ): formErrorDataKey is BraintreeFormErrorDataKeys { + switch (formErrorDataKey) { + case 'number': + case 'cvv': + case 'expirationDate': + case 'postalCode': + case 'cardholderName': + case 'cardType': + return true; + + default: + return false; + } + } +} diff --git a/packages/braintree-integration/src/braintree-payment-options.ts b/packages/braintree-integration/src/braintree-payment-options.ts new file mode 100644 index 0000000000..78a7030508 --- /dev/null +++ b/packages/braintree-integration/src/braintree-payment-options.ts @@ -0,0 +1,284 @@ +import { + BraintreeError, + BraintreeFormErrorsData, + BraintreeVerifyPayload, +} from '@bigcommerce/checkout-sdk/braintree-utils'; +import { StandardError } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +/** + * A set of options that are required to initialize the Braintree payment + * method. You need to provide the options if you want to support 3D Secure + * authentication flow. + * + * ```html + * + *
+ *
+ *
+ *
+ * ``` + * + * ```js + * service.initializePayment({ + * methodId: 'braintree', + * braintree: { + * form: { + * fields: { + * cardNumber: { containerId: 'card-number' }, + * cardName: { containerId: 'card-name' }, + * cardExpiry: { containerId: 'card-expiry' }, + * cardCode: { containerId: 'card-code' }, + * }, + * }, + * }, + * }); + * ``` + * + * Additional options can be passed in to customize the fields and register + * event callbacks. + * + * ```js + * service.initializePayment({ + * methodId: 'braintree', + * creditCard: { + * form: { + * fields: { + * cardNumber: { containerId: 'card-number' }, + * cardName: { containerId: 'card-name' }, + * cardExpiry: { containerId: 'card-expiry' }, + * cardCode: { containerId: 'card-code' }, + * }, + * styles: { + * default: { + * color: '#000', + * }, + * error: { + * color: '#f00', + * }, + * focus: { + * color: '#0f0', + * }, + * }, + * onBlur({ fieldType }) { + * console.log(fieldType); + * }, + * onFocus({ fieldType }) { + * console.log(fieldType); + * }, + * onEnter({ fieldType }) { + * console.log(fieldType); + * }, + * onCardTypeChange({ cardType }) { + * console.log(cardType); + * }, + * onValidate({ errors, isValid }) { + * console.log(errors); + * console.log(isValid); + * }, + * }, + * }, + * }); + * ``` + */ +export interface BraintreePaymentInitializeOptions { + /** + * 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; + + /** + * 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[]; +} + +/** + * A set of options that are required to support 3D Secure authentication flow. + * + * If the customer uses a credit card that has 3D Secure enabled, they will be + * asked to verify their identity when they pay. The verification is done + * through a web page via an iframe provided by the card issuer. + */ +export interface BraintreeThreeDSecureOptions { + /** + * A callback that gets called when the iframe is ready to be added to the + * current page. It is responsible for determining where the iframe should + * be inserted in the DOM. + * + * @param error - Any error raised during the verification process; + * undefined if there is none. + * @param iframe - The iframe element containing the verification web page + * provided by the card issuer. + * @param cancel - A function, when called, will cancel the verification + * process and remove the iframe. + */ + addFrame( + error: Error | undefined, + iframe: HTMLIFrameElement, + cancel: () => Promise | undefined, + ): void; + + /** + * A callback that gets called when the iframe is about to be removed from + * the current page. + */ + removeFrame(): void; + challengeRequested?: boolean; + additionalInformation?: { + acsWindowSize?: '01' | '02' | '03' | '04' | '05'; + }; +} + +export interface BraintreeFormOptions { + fields: BraintreeFormFieldsMap | BraintreeStoredCardFieldsMap; + styles?: BraintreeFormFieldStylesMap; + onBlur?(data: BraintreeFormFieldBlurEventData): void; + onCardTypeChange?(data: BraintreeFormFieldCardTypeChangeEventData): void; + onFocus?(data: BraintreeFormFieldFocusEventData): void; + onValidate?(data: BraintreeFormFieldValidateEventData): void; + onEnter?(data: BraintreeFormFieldEnterEventData): void; +} + +export enum BraintreeFormFieldType { + CardCode = 'cardCode', + CardCodeVerification = 'cardCodeVerification', + CardExpiry = 'cardExpiry', + CardName = 'cardName', + CardNumber = 'cardNumber', + CardNumberVerification = 'cardNumberVerification', +} + +export interface BraintreeFormFieldsMap { + [BraintreeFormFieldType.CardCode]?: BraintreeFormFieldOptions; + [BraintreeFormFieldType.CardExpiry]: BraintreeFormFieldOptions; + [BraintreeFormFieldType.CardName]: BraintreeFormFieldOptions; + [BraintreeFormFieldType.CardNumber]: BraintreeFormFieldOptions; +} + +export interface BraintreeStoredCardFieldsMap { + [BraintreeFormFieldType.CardCodeVerification]?: BraintreeStoredCardFieldOptions; + [BraintreeFormFieldType.CardNumberVerification]?: BraintreeStoredCardFieldOptions; +} + +export interface BraintreeFormFieldOptions { + accessibilityLabel?: string; + containerId: string; + placeholder?: string; +} + +export interface BraintreeStoredCardFieldOptions extends BraintreeFormFieldOptions { + instrumentId: string; +} + +export interface BraintreeFormFieldStylesMap { + default?: BraintreeFormFieldStyles; + error?: BraintreeFormFieldStyles; + focus?: BraintreeFormFieldStyles; +} + +export type BraintreeFormFieldStyles = Partial< + Pick +>; + +export interface BraintreeFormFieldKeyboardEventData { + fieldType: string; + errors?: BraintreeFormErrorsData; +} + +export type BraintreeFormFieldBlurEventData = BraintreeFormFieldKeyboardEventData; +export type BraintreeFormFieldEnterEventData = BraintreeFormFieldKeyboardEventData; +export type BraintreeFormFieldFocusEventData = BraintreeFormFieldKeyboardEventData; + +export interface BraintreeFormFieldCardTypeChangeEventData { + cardType?: string; +} + +export interface BraintreeFormFieldValidateEventData { + errors: { + [BraintreeFormFieldType.CardCode]?: BraintreeFormFieldValidateErrorData[]; + [BraintreeFormFieldType.CardExpiry]?: BraintreeFormFieldValidateErrorData[]; + [BraintreeFormFieldType.CardName]?: BraintreeFormFieldValidateErrorData[]; + [BraintreeFormFieldType.CardNumber]?: BraintreeFormFieldValidateErrorData[]; + [BraintreeFormFieldType.CardCodeVerification]?: BraintreeFormFieldValidateErrorData[]; + [BraintreeFormFieldType.CardNumberVerification]?: BraintreeFormFieldValidateErrorData[]; + }; + isValid: boolean; +} + +export interface BraintreeFormFieldValidateErrorData { + fieldType: string; + message: string; + type: 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', +} diff --git a/packages/braintree-integration/src/index.ts b/packages/braintree-integration/src/index.ts index 0c24f1dc20..4ca780a121 100644 --- a/packages/braintree-integration/src/index.ts +++ b/packages/braintree-integration/src/index.ts @@ -45,3 +45,6 @@ export { default as createBraintreeVisaCheckoutCustomerStrategy } from './braint * Braintree Venmo */ export { default as createBraintreeVenmoButtonStrategy } from './braintree-venmo/create-braintree-venmo-button-strategy'; + +export { default as BraintreeHostedForm } from './braintree-hosted-form/braintree-hosted-form'; +export * from './braintree-payment-options'; diff --git a/packages/braintree-integration/src/mocks/braintree.mock.ts b/packages/braintree-integration/src/mocks/braintree.mock.ts index 511c69a403..064be2862d 100644 --- a/packages/braintree-integration/src/mocks/braintree.mock.ts +++ b/packages/braintree-integration/src/mocks/braintree.mock.ts @@ -1,5 +1,5 @@ import { - Braintree3DSVerifyCardCallback, + Braintree3DSVerifyCardCallback, BraintreeClient, BraintreeModule, BraintreeModuleCreator, BraintreeThreeDSecure, PaypalButtonStyleColorOption, } from '@bigcommerce/checkout-sdk/braintree-utils'; @@ -8,6 +8,7 @@ import { DefaultCheckoutButtonHeight, PaymentMethod, } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { BraintreeThreeDSecureOptions } from '@bigcommerce/checkout-sdk/braintree-integration'; export function getBraintreeAcceleratedCheckoutPaymentMethod(): PaymentMethod { return { @@ -27,6 +28,79 @@ export function getBraintreeAcceleratedCheckoutPaymentMethod(): PaymentMethod { }; } +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 { + 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: [], + }; +} + export function getThreeDSecureMock(): BraintreeThreeDSecure { return { // eslint-disable-next-line @typescript-eslint/no-unused-expressions diff --git a/packages/braintree-utils/src/braintree-integration-service.ts b/packages/braintree-utils/src/braintree-integration-service.ts index acf920af87..13553f7036 100644 --- a/packages/braintree-utils/src/braintree-integration-service.ts +++ b/packages/braintree-utils/src/braintree-integration-service.ts @@ -4,7 +4,7 @@ import { Address, LegacyAddress, NotInitializedError, - NotInitializedErrorType, + NotInitializedErrorType, UnsupportedBrowserError, } from '@bigcommerce/checkout-sdk/payment-integration-api'; import { Overlay } from '@bigcommerce/checkout-sdk/ui'; @@ -23,12 +23,13 @@ import { BraintreePaypal, BraintreePaypalCheckout, BraintreePaypalSdkCreatorConfig, - BraintreeShippingAddressOverride, + BraintreeShippingAddressOverride, BraintreeThreeDSecure, BraintreeTokenizationDetails, - BraintreeTokenizePayload, + BraintreeTokenizePayload, BraintreeVenmoCheckout, PAYPAL_COMPONENTS, } from './types'; import isBraintreeError from './utils/is-braintree-error'; +import { BraintreeVenmoCreatorConfig } from '../../core/src/payment/strategies/braintree'; export interface PaypalConfig { amount: number; @@ -47,6 +48,8 @@ export default class BraintreeIntegrationService { private dataCollectors: BraintreeDataCollectors = {}; private paypalCheckout?: BraintreePaypalCheckout; private braintreePaypal?: Promise; + private threeDS?: Promise; + private venmoCheckout?: Promise; constructor( private braintreeScriptLoader: BraintreeScriptLoader, @@ -97,7 +100,6 @@ export default class BraintreeIntegrationService { if (!this.client) { const clientToken = this.getClientTokenOrThrow(); const clientCreator = await this.braintreeScriptLoader.loadClient(); - this.client = clientCreator.create({ authorization: clientToken }); } @@ -308,6 +310,54 @@ export default class BraintreeIntegrationService { // this._visaCheckout = undefined; } + 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; + } + + async getVenmoCheckout( + onSuccess: (braintreeVenmoCheckout: BraintreeVenmoCheckout) => void, + onError: (error: BraintreeError | UnsupportedBrowserError) => void, + venmoConfig?: BraintreeVenmoCreatorConfig, + ): Promise { + if (!this.venmoCheckout) { + const client = await this.getClient(); + + const venmoCheckout = await this.braintreeScriptLoader.loadVenmoCheckout(); + + const venmoCheckoutConfig = { + client, + allowDesktop: true, + paymentMethodUsage: 'multi_use', + ...(venmoConfig || {}), + }; + + const venmoCheckoutCallback = ( + error: BraintreeError, + braintreeVenmoCheckout: BraintreeVenmoCheckout, + ): void => { + if (error) { + return onError(error); + } + + if (!braintreeVenmoCheckout.isBrowserSupported()) { + return onError(new UnsupportedBrowserError()); + } + + onSuccess(braintreeVenmoCheckout); + }; + + this.venmoCheckout = venmoCheckout.create(venmoCheckoutConfig, venmoCheckoutCallback); + } + + return this.venmoCheckout; + } + private teardownModule(module?: BraintreeModule) { return module ? module.teardown() : Promise.resolve(); } diff --git a/packages/braintree-utils/src/braintree-payment-processor.spec.ts b/packages/braintree-utils/src/braintree-payment-processor.spec.ts new file mode 100644 index 0000000000..76c492548c --- /dev/null +++ b/packages/braintree-utils/src/braintree-payment-processor.spec.ts @@ -0,0 +1,521 @@ +import { noop } from 'lodash'; + +import { getBillingAddress } from '../../../billing/billing-addresses.mock'; +import { NotInitializedError } from '../../../common/error/errors'; +import { PaymentArgumentInvalidError, PaymentMethodCancelledError } from '../../errors'; +import { NonceInstrument } from '../../payment'; + +import { BraintreeClient, BraintreeThreeDSecure } from './braintree'; +import BraintreeHostedForm from './braintree-hosted-form'; +import { BraintreeFormOptions } from './braintree-payment-options'; +import BraintreePaymentProcessor from './braintree-payment-processor'; +import BraintreeSDKCreator from './braintree-sdk-creator'; +import { + getBraintreePaymentData, + getBraintreeRequestData, + getClientMock, + getThreeDSecureMock, + getThreeDSecureOptionsMock, + getTokenizeResponseBody, + getVerifyPayload, +} from './braintree.mock'; + +jest.mock('@braintree/browser-detection', () => ({ + supportsPopups: jest.fn(() => false), +})); + +describe('BraintreePaymentProcessor', () => { + let braintreeSDKCreator: BraintreeSDKCreator; + let braintreeHostedForm: BraintreeHostedForm; + + const clientToken = 'clientToken'; + + beforeEach(() => { + braintreeSDKCreator = {} as BraintreeSDKCreator; + braintreeHostedForm = {} as BraintreeHostedForm; + }); + + it('creates a instance of the payment processor', () => { + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + expect(braintreePaymentProcessor).toBeInstanceOf(BraintreePaymentProcessor); + }); + + describe('#initialize()', () => { + it('initializes the sdk creator with the client token', () => { + braintreeSDKCreator.initialize = jest.fn(); + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + braintreePaymentProcessor.initialize(clientToken); + + expect(braintreeSDKCreator.initialize).toHaveBeenCalledWith(clientToken); + }); + }); + + describe('#deinitialize()', () => { + it('calls teardown in the braintree sdk creator', async () => { + braintreeSDKCreator.teardown = jest.fn(); + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + await braintreePaymentProcessor.deinitialize(); + + expect(braintreeSDKCreator.teardown).toHaveBeenCalled(); + }); + }); + + describe('#preloadPaypalCheckout', () => { + it('loading paypal sdk via paypal checkout', async () => { + braintreeSDKCreator.getPaypalCheckout = jest.fn(); + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + const config = { + isCreditEnabled: true, + currency: 'USD', + intent: undefined, + }; + + const onSuccess = jest.fn(); + const handleError = jest.fn(); + + await braintreePaymentProcessor.preloadPaypalCheckout(config, onSuccess, handleError); + + expect(braintreeSDKCreator.getPaypalCheckout).toHaveBeenCalled(); + }); + }); + + describe('#tokenizeCard()', () => { + let clientMock: BraintreeClient; + let braintreePaymentProcessor: BraintreePaymentProcessor; + + beforeEach(() => { + clientMock = getClientMock(); + jest.spyOn(clientMock, 'request').mockReturnValue( + Promise.resolve(getTokenizeResponseBody()), + ); + braintreeSDKCreator.getClient = jest.fn().mockReturnValue(Promise.resolve(clientMock)); + braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + }); + + it('tokenizes a card', async () => { + const tokenizedCard = await braintreePaymentProcessor.tokenizeCard( + getBraintreePaymentData(), + getBillingAddress(), + ); + + expect(tokenizedCard).toEqual({ + nonce: 'demo_nonce', + bin: 'demo_bin', + }); + }); + + it('calls the braintree client request with the correct information', async () => { + await braintreePaymentProcessor.tokenizeCard( + getBraintreePaymentData(), + getBillingAddress(), + ); + + expect(clientMock.request).toHaveBeenCalledWith(getBraintreeRequestData()); + }); + + it('throws an error when tokenising card with invalid form data', async () => { + const payment = getBraintreePaymentData(); + + payment.paymentData = undefined; + + await expect( + braintreePaymentProcessor.tokenizeCard(payment, getBillingAddress()), + ).rejects.toThrow(PaymentArgumentInvalidError); + }); + }); + + describe('#verifyCard()', () => { + let clientMock: BraintreeClient; + let braintreePaymentProcessor: BraintreePaymentProcessor; + + beforeEach(() => { + clientMock = getClientMock(); + + jest.spyOn(clientMock, 'request').mockResolvedValue(getTokenizeResponseBody()); + + braintreeSDKCreator.initialize = jest.fn(); + braintreeSDKCreator.getClient = jest.fn().mockReturnValue(Promise.resolve(clientMock)); + + braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + jest.spyOn(braintreePaymentProcessor, 'tokenizeCard').mockReturnValue( + // TODO: remove ts-ignore and update test with related type (PAYPAL-4383) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + Promise.resolve({ nonce: 'my_nonce' }), + ); + jest.spyOn(braintreePaymentProcessor, 'challenge3DSVerification').mockReturnValue( + Promise.resolve({ nonce: 'three_ds_nonce' }), + ); + }); + + it('tokenizes the card with the right params', async () => { + await braintreePaymentProcessor.verifyCard( + getBraintreePaymentData(), + getBillingAddress(), + 122, + ); + + expect(braintreePaymentProcessor.tokenizeCard).toHaveBeenCalledWith( + getBraintreePaymentData(), + getBillingAddress(), + ); + }); + + it('verifies the card using 3DS', async () => { + const verifiedCard = await braintreePaymentProcessor.verifyCard( + getBraintreePaymentData(), + getBillingAddress(), + 122, + ); + + expect(verifiedCard).toEqual({ nonce: 'three_ds_nonce' }); + }); + }); + + describe('#appendSessionId()', () => { + let processedPayment: NonceInstrument; + + beforeEach(() => { + const dataCollector = { + deviceData: 'my_device_session_id', + }; + + braintreeSDKCreator.getDataCollector = jest + .fn() + .mockReturnValue(Promise.resolve(dataCollector)); + processedPayment = { nonce: 'my_nonce' }; + }); + + it('appends data to a processed payment', async () => { + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + const expected = await braintreePaymentProcessor.appendSessionId( + Promise.resolve(processedPayment), + ); + + expect(expected).toEqual({ + ...processedPayment, + deviceSessionId: 'my_device_session_id', + }); + }); + }); + + describe('#getSessionId()', () => { + it('appends data to a processed payment', async () => { + braintreeSDKCreator.getDataCollector = jest.fn().mockResolvedValue({ + deviceData: 'my_device_session_id', + }); + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + const expected = await braintreePaymentProcessor.getSessionId(); + + expect(expected).toBe('my_device_session_id'); + }); + }); + + describe('#initializeHostedForm', () => { + let hostedFormInitializationOptions: BraintreeFormOptions; + + it('initializes the hosted form', () => { + braintreeHostedForm.initialize = jest.fn(); + + const unsupportedCardBrands = ['american-express', 'discover']; + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + hostedFormInitializationOptions = { + 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' }, + }, + }; + + braintreePaymentProcessor.initializeHostedForm( + hostedFormInitializationOptions, + unsupportedCardBrands, + ); + + expect(braintreeHostedForm.initialize).toHaveBeenCalledWith( + hostedFormInitializationOptions, + unsupportedCardBrands, + ); + }); + }); + + describe('#deinitializeHostedForm', () => { + it('deinitializes the hosted form', async () => { + braintreeHostedForm.deinitialize = jest.fn(); + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + await braintreePaymentProcessor.deinitializeHostedForm(); + + expect(braintreeHostedForm.deinitialize).toHaveBeenCalled(); + }); + }); + + describe('#isInitializedHostedForm()', () => { + it('returns false is hosted form is not initialized', async () => { + const braintreeHostedForm = new BraintreeHostedForm(braintreeSDKCreator); + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + await braintreeHostedForm.initialize({ fields: {} }); + + expect(braintreePaymentProcessor.isInitializedHostedForm()).toBeFalsy(); + }); + }); + + describe('#tokenizeHostedForm', () => { + it('tokenizes credit card with hosted form', () => { + braintreeHostedForm.tokenize = jest.fn(); + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + braintreePaymentProcessor.tokenizeHostedForm(getBillingAddress()); + + expect(braintreeHostedForm.tokenize).toHaveBeenCalledWith(getBillingAddress()); + }); + }); + + describe('#tokenizeHostedFormForStoredCardVerification', () => { + it('tokenizes stored credit card with hosted form', () => { + braintreeHostedForm.tokenizeForStoredCardVerification = jest.fn(); + + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + braintreePaymentProcessor.tokenizeHostedFormForStoredCardVerification(); + + expect(braintreeHostedForm.tokenizeForStoredCardVerification).toHaveBeenCalled(); + }); + }); + + describe('#verifyCardWithHostedForm', () => { + it('verifies the card with hosted form using 3DS', async () => { + const braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + braintreeHostedForm.tokenize = jest.fn(); + + jest.spyOn(braintreePaymentProcessor, 'challenge3DSVerification').mockResolvedValue({ + nonce: 'three_ds_nonce', + }); + // TODO: remove ts-ignore and update test with related type (PAYPAL-4383) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + jest.spyOn(braintreeHostedForm, 'tokenize').mockResolvedValue({ + nonce: 'tokenized_nonce', + }); + + const verifiedCard = await braintreePaymentProcessor.verifyCardWithHostedForm( + getBillingAddress(), + 122, + ); + + expect(braintreeHostedForm.tokenize).toHaveBeenCalledWith(getBillingAddress()); + expect(verifiedCard).toEqual({ nonce: 'three_ds_nonce' }); + }); + }); + + describe('#challenge3DSVerification()', () => { + let clientMock: BraintreeClient; + let threeDSecureMock: BraintreeThreeDSecure; + let braintreePaymentProcessor: BraintreePaymentProcessor; + let cancelVerifyCard: () => void; + + beforeEach(() => { + clientMock = getClientMock(); + threeDSecureMock = getThreeDSecureMock(); + + jest.spyOn(clientMock, 'request').mockResolvedValue(getTokenizeResponseBody()); + + braintreeSDKCreator.initialize = jest.fn(); + braintreeSDKCreator.getClient = jest.fn().mockReturnValue(Promise.resolve(clientMock)); + braintreeSDKCreator.get3DS = jest + .fn() + .mockReturnValue(Promise.resolve(threeDSecureMock)); + + braintreePaymentProcessor = new BraintreePaymentProcessor( + braintreeSDKCreator, + braintreeHostedForm, + ); + + braintreePaymentProcessor.initialize(clientToken, { + threeDSecure: { + ...getThreeDSecureOptionsMock(), + addFrame: (_error, _iframe, cancel) => { + cancelVerifyCard = cancel; + }, + }, + }); + }); + + it('throws if no 3DS modal handler was supplied on initialization', () => { + braintreePaymentProcessor.initialize('clientToken'); + + return expect( + braintreePaymentProcessor.challenge3DSVerification( + { + nonce: 'tokenization_nonce', + bin: '123456', + }, + 122, + ), + ).rejects.toThrow(NotInitializedError); + }); + + it('challenges 3DS verifies the card using 3DS', async () => { + jest.spyOn(threeDSecureMock, 'verifyCard').mockReturnValue( + Promise.resolve({ + nonce: 'three_ds_nonce', + }), + ); + + const verifiedCard = await braintreePaymentProcessor.challenge3DSVerification( + { + nonce: 'tokenization_nonce', + bin: '123456', + }, + 122, + ); + + expect(verifiedCard).toEqual({ nonce: 'three_ds_nonce' }); + }); + + it('calls the verification service with the right values', async () => { + await braintreePaymentProcessor.challenge3DSVerification( + { + nonce: 'tokenization_nonce', + bin: '123456', + }, + 122, + ); + + expect(threeDSecureMock.verifyCard).toHaveBeenCalledWith({ + addFrame: expect.any(Function), + removeFrame: expect.any(Function), + challengeRequested: expect.any(Boolean), + amount: 122, + bin: '123456', + nonce: 'tokenization_nonce', + onLookupComplete: expect.any(Function), + collectDeviceData: true, + additionalInformation: { + acsWindowSize: '01', + }, + }); + }); + + // TODO: CHECKOUT-7766 + describe('when cancel function gets called', () => { + beforeEach(() => { + jest.spyOn(threeDSecureMock, 'verifyCard').mockImplementation(({ addFrame }) => { + addFrame(new Error(), document.createElement('iframe')); + + return new Promise(noop); + }); + + jest.spyOn(threeDSecureMock, 'cancelVerifyCard').mockReturnValue( + Promise.resolve(getVerifyPayload()), + ); + }); + + it('cancels card verification', async () => { + braintreePaymentProcessor + .challenge3DSVerification( + { + nonce: 'tokenization_nonce', + bin: '123456', + }, + 122, + ) + .catch(noop); + + await new Promise((resolve) => process.nextTick(resolve)); + cancelVerifyCard(); + + expect(threeDSecureMock.cancelVerifyCard).toHaveBeenCalled(); + }); + + it('rejects the return promise', async () => { + const promise = braintreePaymentProcessor.challenge3DSVerification( + { + nonce: 'tokenization_nonce', + bin: '123456', + }, + 122, + ); + + await new Promise((resolve) => process.nextTick(resolve)); + cancelVerifyCard(); + + return expect(promise).rejects.toBeInstanceOf(PaymentMethodCancelledError); + }); + + it('resolves with verify payload', async () => { + braintreePaymentProcessor.challenge3DSVerification( + { + nonce: 'tokenization_nonce', + bin: '123456', + }, + 122, + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + const response = await cancelVerifyCard(); + + expect(response).toEqual(getVerifyPayload()); + }); + }); + }); +}); diff --git a/packages/braintree-utils/src/braintree-payment-processor.ts b/packages/braintree-utils/src/braintree-payment-processor.ts new file mode 100644 index 0000000000..87181ef662 --- /dev/null +++ b/packages/braintree-utils/src/braintree-payment-processor.ts @@ -0,0 +1,276 @@ +import { isEmpty } from 'lodash'; + +import { + Address, + CancellablePromise, + CreditCardInstrument, + NonceInstrument, + NotInitializedError, + NotInitializedErrorType, + OrderPaymentRequestBody, + PaymentArgumentInvalidError, + PaymentInvalidFormError, + PaymentInvalidFormErrorDetails, + PaymentMethodCancelledError, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { + BraintreeError, + BraintreeFormOptions, + BraintreeIntegrationService, + BraintreePaypalCheckout, + BraintreePaypalSdkCreatorConfig, + BraintreeRequestData, + BraintreeThreeDSecure, + BraintreeVenmoCheckout, + BraintreeVenmoCreatorConfig, + BraintreeVerifyPayload, + TokenizationPayload, +} from './index'; +import { + BraintreePaymentInitializeOptions, + BraintreeThreeDSecureOptions, +} from '@bigcommerce/checkout-sdk/braintree-integration'; +import isCreditCardInstrumentLike from './is-credit-card-instrument-like'; +import { BraintreeHostedForm } from '@bigcommerce/checkout-sdk/braintree-integration'; + +export default class BraintreePaymentProcessor { + private threeDSecureOptions?: BraintreeThreeDSecureOptions; + + constructor( + private braintreeIntegrationService: BraintreeIntegrationService, + private braintreeHostedForm: BraintreeHostedForm, + ) {} + + initialize(clientToken: string, options?: BraintreePaymentInitializeOptions): void { + this.braintreeIntegrationService.initialize(clientToken); + this.threeDSecureOptions = options?.threeDSecure; + } + + deinitialize(): Promise { + return this.braintreeIntegrationService.teardown(); + } + + preloadPaypalCheckout( + paypalCheckoutConfig: Partial, + onSuccess: (instance: BraintreePaypalCheckout) => void, + onError: (error: BraintreeError) => void, + ) { + return this.braintreeIntegrationService.getPaypalCheckout( + paypalCheckoutConfig, + onSuccess, + onError, + ); + } + + async tokenizeCard( + payment: OrderPaymentRequestBody, + 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.braintreeIntegrationService.getClient(); + const { creditCards } = await client.request(requestData); + + return { + nonce: creditCards[0].nonce, + bin: creditCards[0].details?.bin, + }; + } + + async verifyCard( + payment: OrderPaymentRequestBody, + billingAddress: Address, + amount: number, + ): Promise { + const tokenizationPayload = await this.tokenizeCard(payment, billingAddress); + + return this.challenge3DSVerification(tokenizationPayload, amount); + } + + getSessionId(): Promise { + return this.braintreeIntegrationService.getDataCollector().then(({ deviceData }) => deviceData); + } + + /** + * @deprecated Use getSessionId() and combine them in the consumer. + */ + appendSessionId(processedPayment: Promise): Promise { + return processedPayment + .then((paymentData) => + Promise.all([paymentData, this.braintreeIntegrationService.getDataCollector()]), + ) + .then(([paymentData, { deviceData }]) => ({ + ...paymentData, + deviceSessionId: deviceData, + })); + } + + async initializeHostedForm( + options: BraintreeFormOptions, + unsupportedCardBrands?: string[], + ): Promise { + return this.braintreeHostedForm.initialize(options, unsupportedCardBrands); + } + + validateHostedForm() { + return this.braintreeHostedForm.validate(); + } + + isInitializedHostedForm(): boolean { + return this.braintreeHostedForm.isInitialized(); + } + + async deinitializeHostedForm(): Promise { + await this.braintreeHostedForm.deinitialize(); + } + + tokenizeHostedForm(billingAddress: Address): Promise { + return this.braintreeHostedForm.tokenize(billingAddress); + } + + tokenizeHostedFormForStoredCardVerification(): Promise { + return this.braintreeHostedForm.tokenizeForStoredCardVerification(); + } + + async verifyCardWithHostedForm( + billingAddress: Address, + amount: number, + ): Promise { + const tokenizationPayload = await this.braintreeHostedForm.tokenize(billingAddress); + + return this.challenge3DSVerification(tokenizationPayload, amount); + } + + async challenge3DSVerification( + tokenizationPayload: TokenizationPayload, + amount: number, + ): Promise { + const threeDSecure = await this.braintreeIntegrationService.get3DS(); + + return this.present3DSChallenge(threeDSecure, amount, tokenizationPayload); + } + + async getVenmoCheckout( + venmoConfig?: BraintreeVenmoCreatorConfig, + ): Promise { + return new Promise((resolve, reject) => { + this.braintreeIntegrationService.getVenmoCheckout(resolve, reject, venmoConfig); + }); + } + + private getErrorsRequiredFields( + paymentData: CreditCardInstrument, + ): PaymentInvalidFormErrorDetails { + const { ccNumber, ccExpiry } = paymentData; + const errors: PaymentInvalidFormErrorDetails = {}; + + if (!ccNumber) { + errors.ccNumber = [ + { + message: 'Credit card number is required', + type: 'required', + }, + ]; + } + + if (!ccExpiry) { + errors.ccExpiry = [ + { + message: 'Expiration date is required', + type: 'required', + }, + ]; + } + + return errors; + } + + 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(); + + verification.cancel(new PaymentMethodCancelledError()); + + return response; + }; + + const roundedAmount = amount.toFixed(2); + + const verification = new CancellablePromise( + threeDSecure.verifyCard({ + addFrame: (error, iframe) => { + addFrame && addFrame(error, iframe, cancelVerifyCard); + }, + amount: Number(roundedAmount), + bin, + challengeRequested, + nonce, + removeFrame, + onLookupComplete: (_data, next) => { + next(); + }, + collectDeviceData: true, + additionalInformation, + }), + ); + + return verification.promise; + } + + 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', + }; + } +} diff --git a/packages/braintree-utils/src/braintree.ts b/packages/braintree-utils/src/braintree.ts index b321bf3f20..83dea357a2 100644 --- a/packages/braintree-utils/src/braintree.ts +++ b/packages/braintree-utils/src/braintree.ts @@ -471,6 +471,7 @@ export interface BraintreeVenmoCheckout extends BraintreeModule { export interface BraintreeVenmoCreatorConfig extends BraintreeModuleCreatorConfig { allowDesktop: boolean; paymentMethodUsage: string; + mobileWebFallBack?: boolean } /** diff --git a/packages/braintree-utils/src/index.ts b/packages/braintree-utils/src/index.ts index f51fb2a154..7d8b7d65d3 100644 --- a/packages/braintree-utils/src/index.ts +++ b/packages/braintree-utils/src/index.ts @@ -13,3 +13,5 @@ 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 { default as BraintreePaymentProcessor } from './braintree-payment-processor'; diff --git a/packages/braintree-utils/src/is-braintree-form-fields-map.ts b/packages/braintree-utils/src/is-braintree-form-fields-map.ts new file mode 100644 index 0000000000..c3256ea323 --- /dev/null +++ b/packages/braintree-utils/src/is-braintree-form-fields-map.ts @@ -0,0 +1,16 @@ +import { BraintreeFormFieldsMap, BraintreeStoredCardFieldsMap } from './braintree'; + +export function isBraintreeFormFieldsMap( + fields: BraintreeFormFieldsMap | BraintreeStoredCardFieldsMap, +): fields is BraintreeFormFieldsMap { + return !!(fields as BraintreeFormFieldsMap).cardNumber; +} + +export function isBraintreeStoredCardFieldsMap( + fields: BraintreeFormFieldsMap | BraintreeStoredCardFieldsMap, +): fields is BraintreeStoredCardFieldsMap { + return !!( + (fields as BraintreeStoredCardFieldsMap).cardCodeVerification || + (fields as BraintreeStoredCardFieldsMap).cardNumberVerification + ); +} diff --git a/packages/braintree-utils/src/is-braintree-hosted-form-error.ts b/packages/braintree-utils/src/is-braintree-hosted-form-error.ts new file mode 100644 index 0000000000..1ac6fff596 --- /dev/null +++ b/packages/braintree-utils/src/is-braintree-hosted-form-error.ts @@ -0,0 +1,24 @@ +import { BraintreeHostedFormError } from './braintree'; +import { isBraintreeError } from './utils'; + +function isValidInvalidFieldKeys(invalidFieldKeys: any): invalidFieldKeys is string[] { + return ( + Array.isArray(invalidFieldKeys) && invalidFieldKeys.every((key) => typeof key === 'string') + ); +} + +export function isBraintreeHostedFormError(error: any): error is BraintreeHostedFormError { + if (!isBraintreeError(error)) { + return false; + } + + const { details } = error; + + return ( + details === undefined || + (typeof details === 'object' && + details !== null && + (details as { invalidFieldKeys?: unknown }).invalidFieldKeys === undefined) || + isValidInvalidFieldKeys((details as { invalidFieldKeys?: unknown }).invalidFieldKeys) + ); +} diff --git a/packages/braintree-utils/src/is-braintree-supported-card-brand.ts b/packages/braintree-utils/src/is-braintree-supported-card-brand.ts new file mode 100644 index 0000000000..267eda1ef2 --- /dev/null +++ b/packages/braintree-utils/src/is-braintree-supported-card-brand.ts @@ -0,0 +1,9 @@ +import { BraintreeSupportedCardBrands } from '../../braintree-integration/src/braintree-payment-options'; + +export const isBraintreeSupportedCardBrand = ( + cardBrand: string, +): cardBrand is BraintreeSupportedCardBrands => { + const supportedCardBrands = Object.values(BraintreeSupportedCardBrands); + + return supportedCardBrands.includes(cardBrand as BraintreeSupportedCardBrands); +}; diff --git a/packages/braintree-utils/src/is-credit-card-instrument-like.ts b/packages/braintree-utils/src/is-credit-card-instrument-like.ts new file mode 100644 index 0000000000..e3940d4b57 --- /dev/null +++ b/packages/braintree-utils/src/is-credit-card-instrument-like.ts @@ -0,0 +1,13 @@ +import { CreditCardInstrument } from '@bigcommerce/checkout-sdk/payment-integration-api'; + + +export default function isCreditCardInstrumentLike( + instrument: any, +): instrument is CreditCardInstrument { + return ( + instrument && + typeof instrument.ccExpiry === 'object' && + typeof instrument.ccNumber === 'string' && + typeof instrument.ccName === 'string' + ); +} diff --git a/packages/braintree-utils/src/mocks/braintree-modules.mock.ts b/packages/braintree-utils/src/mocks/braintree-modules.mock.ts index 8fe900f0de..a42aa7b0a5 100644 --- a/packages/braintree-utils/src/mocks/braintree-modules.mock.ts +++ b/packages/braintree-utils/src/mocks/braintree-modules.mock.ts @@ -43,6 +43,7 @@ export function getModuleCreatorMock( */ export function getClientMock(): BraintreeClient { return { + getVersion: jest.fn(), request: jest.fn(), }; } diff --git a/packages/braintree-utils/src/mocks/braintree.mock.ts b/packages/braintree-utils/src/mocks/braintree.mock.ts index 41b65042a5..14b016b7d2 100644 --- a/packages/braintree-utils/src/mocks/braintree.mock.ts +++ b/packages/braintree-utils/src/mocks/braintree.mock.ts @@ -22,6 +22,7 @@ import { } from '../types'; import { getVisaCheckoutTokenizedPayload } from './visacheckout.mock'; +import BillingAddress from '../../../core/src/billing/billing-address'; export function getBraintree(): PaymentMethod { return { @@ -409,3 +410,24 @@ export function getBraintreeAddress(): BraintreeShippingAddressOverride { recipientName: 'Test Tester', }; } + +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: [], + }; +} diff --git a/packages/braintree-utils/src/types.ts b/packages/braintree-utils/src/types.ts index 564d7b36f2..467fce2be6 100644 --- a/packages/braintree-utils/src/types.ts +++ b/packages/braintree-utils/src/types.ts @@ -27,6 +27,29 @@ export interface BraintreeModule { teardown(): Promise; } +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; +} + /** * * Braintree Window @@ -46,6 +69,7 @@ export type BraintreeClientCreator = BraintreeModuleCreator; export interface BraintreeClient { request(payload: BraintreeClientRequestPayload): Promise; + getVersion(): string | void; } export interface BraintreeClientRequestPayload { diff --git a/packages/core/src/payment/strategies/braintree/braintree.mock.ts b/packages/core/src/payment/strategies/braintree/braintree.mock.ts index 10c8000ec8..b11661b856 100644 --- a/packages/core/src/payment/strategies/braintree/braintree.mock.ts +++ b/packages/core/src/payment/strategies/braintree/braintree.mock.ts @@ -260,3 +260,5 @@ export function getThreeDSecureOptionsMock(): BraintreeThreeDSecureOptions { }, }; } + + diff --git a/packages/payment-integration-api/src/payment/instrument.ts b/packages/payment-integration-api/src/payment/instrument.ts index b6762e0a15..2b3b3bab59 100644 --- a/packages/payment-integration-api/src/payment/instrument.ts +++ b/packages/payment-integration-api/src/payment/instrument.ts @@ -1,4 +1,4 @@ -type PaymentInstrument = CardInstrument | AccountInstrument; +type PaymentInstrument = | CardInstrument | AccountInstrument; export default PaymentInstrument;