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