diff --git a/packages/stripe-integration/src/index.ts b/packages/stripe-integration/src/index.ts index a6784b0bcf..d5d625c0f6 100644 --- a/packages/stripe-integration/src/index.ts +++ b/packages/stripe-integration/src/index.ts @@ -1,6 +1,7 @@ export { default as createStripeV3PaymentStrategy } from './stripev3/create-stripev3-payment-strategy'; export { default as createStripeUPEPaymentStrategy } from './stripe-upe/create-stripe-upe-payment-strategy'; export { default as createStripeUPECustomerStrategy } from './stripe-upe/create-stripe-upe-customer-strategy'; +export { default as createStripeLinkV2CustomerStrategy } from './stripe-linkv2/create-stripe-linkv2-customer-strategy'; export { default as createStripeOCSPaymentStrategy } from './stripe-upe/create-stripe-ocs-payment-strategy'; export { default as StripeScriptLoader } from './stripev3/stripev3-script-loader'; diff --git a/packages/stripe-integration/src/stripe-linkv2/create-stripe-linkv2-customer-strategy.ts b/packages/stripe-integration/src/stripe-linkv2/create-stripe-linkv2-customer-strategy.ts new file mode 100644 index 0000000000..0607cf33dc --- /dev/null +++ b/packages/stripe-integration/src/stripe-linkv2/create-stripe-linkv2-customer-strategy.ts @@ -0,0 +1,21 @@ +import { getScriptLoader } from '@bigcommerce/script-loader'; + +import { + CustomerStrategyFactory, + toResolvableModule, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import StripeUPEScriptLoader from '../stripe-upe/stripe-upe-script-loader'; + +import StripeLinkV2CustomerStrategy from './stripe-linkv2-customer-strategy'; + +const createStripeLinkV2CustomerStrategy: CustomerStrategyFactory = ( + paymentIntegrationService, +) => { + return new StripeLinkV2CustomerStrategy( + paymentIntegrationService, + new StripeUPEScriptLoader(getScriptLoader()), + ); +}; + +export default toResolvableModule(createStripeLinkV2CustomerStrategy, [{ id: 'stripeupe' }]); diff --git a/packages/stripe-integration/src/stripe-linkv2/stripe-linkv2-customer-strategy.ts b/packages/stripe-integration/src/stripe-linkv2/stripe-linkv2-customer-strategy.ts new file mode 100644 index 0000000000..29154ccbd5 --- /dev/null +++ b/packages/stripe-integration/src/stripe-linkv2/stripe-linkv2-customer-strategy.ts @@ -0,0 +1,294 @@ +import { round } from 'lodash'; + +import { + createCurrencyService, + CurrencyService, + CustomerInitializeOptions, + CustomerStrategy, + InvalidArgumentError, + MissingDataError, + MissingDataErrorType, + PaymentIntegrationService, + ShippingOption, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { isStripeUPEPaymentMethodLike } from '../stripe-upe/is-stripe-upe-payment-method-like'; +import { StripeElementType, StripeStringConstants } from '../stripe-upe/stripe-upe'; +import StripeUPEScriptLoader from '../stripe-upe/stripe-upe-script-loader'; +import { WithStripeUPECustomerInitializeOptions } from '../stripe-upe/stripeupe-customer-initialize-options'; + +import { + StripeExpressCheckoutClient, + StripeExpressCheckoutElement, + StripeExpressCheckoutElementCreateOptions, + StripeExpressCheckoutElements, + StripeExpressCheckoutOptions, + StripeLinkV2Event, + StripeLinkV2ShippingRates, +} from './types'; + +export default class StripeLinkV2CustomerStrategy implements CustomerStrategy { + private _stripeExpressCheckoutClient?: StripeExpressCheckoutClient; + private _stripeElements?: StripeExpressCheckoutElements; + private _expressCheckoutElement?: StripeExpressCheckoutElement; + private _currencyService?: CurrencyService; + + constructor( + private paymentIntegrationService: PaymentIntegrationService, + private scriptLoader: StripeUPEScriptLoader, + ) {} + + async initialize( + options: CustomerInitializeOptions & WithStripeUPECustomerInitializeOptions, + ): Promise { + if (!options.stripeupe || !options.methodId) { + throw new InvalidArgumentError( + `Unable to proceed because "options" argument is not provided.`, + ); + } + + const { container, isLoading } = options.stripeupe; + + Object.entries(options.stripeupe).forEach(([key, value]) => { + if (!value) { + throw new InvalidArgumentError( + `Unable to proceed because "${key}" argument is not provided.`, + ); + } + }); + + const state = this.paymentIntegrationService.getState(); + const paymentMethod = state.getPaymentMethodOrThrow(options.methodId); + + if (!isStripeUPEPaymentMethodLike(paymentMethod)) { + throw new MissingDataError(MissingDataErrorType.MissingPaymentToken); + } + + const { + initializationData: { stripePublishableKey }, + } = paymentMethod; + + console.log('stripePublishableKey', stripePublishableKey); + + // this._stripeUPEClient = await this.scriptLoader.getStripeLinkV2Client(stripePublishableKey); + this._stripeExpressCheckoutClient = await this.scriptLoader.getStripeLinkV2Client( + 'pk_test_iyRKkVUt0YWpJ3Lq7mfsw3VW008KiFDH4s', + ); + + this.mountExpressCheckoutElement(container, this._stripeExpressCheckoutClient); + + if (isLoading) { + isLoading(false); + } + + return Promise.resolve(); + } + + signIn() { + return Promise.resolve(); + } + + signOut() { + return Promise.resolve(); + } + + executePaymentMethodCheckout() { + return Promise.resolve(); + } + + deinitialize(): Promise { + return Promise.resolve(); + } + + private mountExpressCheckoutElement( + container: string, + stripeExpressCheckoutClient: StripeExpressCheckoutClient, + ) { + const expressCheckoutOptions: StripeExpressCheckoutElementCreateOptions = { + paymentMethods: { + link: StripeStringConstants.AUTO, + applePay: StripeStringConstants.NEVER, + googlePay: StripeStringConstants.NEVER, + amazonPay: StripeStringConstants.NEVER, + paypal: StripeStringConstants.NEVER, + }, + buttonHeight: 40, + }; + + const { cartAmount: amount } = this.paymentIntegrationService.getState().getCartOrThrow(); + const elementsOptions: StripeExpressCheckoutOptions = { + mode: 'payment', + amount: amount * 100, + currency: this.getCurrency(), + }; + + this._stripeElements = stripeExpressCheckoutClient.elements(elementsOptions); + + this._expressCheckoutElement = this._stripeElements.create( + StripeElementType.EXPRESS_CHECKOUT, + expressCheckoutOptions, + ); + this._expressCheckoutElement.mount(`#${container}`); + this.initializeEvents(this._expressCheckoutElement); + } + + /** Events * */ + + private initializeEvents(expressCheckoutElement: StripeExpressCheckoutElement): void { + expressCheckoutElement.on('click', async (event: StripeLinkV2Event) => + this.onClick(event), + ); + expressCheckoutElement.on('shippingaddresschange', async (event: StripeLinkV2Event) => + this.onShippingAddressChange(event), + ); + expressCheckoutElement.on('shippingratechange', async (event: StripeLinkV2Event) => + this.onShippingRateChange(event), + ); + } + + private async onClick(event: StripeLinkV2Event) { + const countries = await this.paymentIntegrationService.loadShippingCountries(); + const allowedShippingCountries = countries + .getShippingCountries() + ?.map((country) => country.code); + + event.resolve({ + allowedShippingCountries, + shippingAddressRequired: true, + shippingRates: [ + { id: 'mock', amount: 40, displayName: 'Mock should not be displayed' }, + ], + billingAddressRequired: true, + emailRequired: true, + phoneNumberRequired: true, + }); + } + + private async onShippingAddressChange(event: StripeLinkV2Event) { + const shippingAddress = event.address; + const result = { + firstName: '', + lastName: '', + phone: '', + company: '', + address1: shippingAddress?.line1 || '', + address2: shippingAddress?.line2 || '', + city: shippingAddress?.city || '', + countryCode: shippingAddress?.country || '', + postalCode: shippingAddress?.postal_code || '', + stateOrProvince: shippingAddress?.state || '', + stateOrProvinceCode: '', + customFields: [], + }; + + await this.paymentIntegrationService.updateShippingAddress(result); + + const shippingRates = await this.getAvailableShippingOptions(); + + if (this._stripeElements) { + this._stripeElements.update({ + currency: this.getCurrency(), + mode: 'payment', + amount: this.getTotalPrice(), + }); + } + + event.resolve({ + shippingRates, + }); + } + + private async onShippingRateChange(event: StripeLinkV2Event) { + const gatewayId ='stripeupe'; + const methodId= 'card'; + await this.paymentIntegrationService.loadPaymentMethod(gatewayId, { + params: { method: methodId }, + }); + + const state = this.paymentIntegrationService.getState(); + const paymentMethod = state.getPaymentMethodOrThrow(methodId, gatewayId); + const { clientToken } = paymentMethod; + + console.log('shippingratechange totalPrice', this.getTotalPrice()); + if (this._stripeElements) { + this._stripeElements.update({ + clientSecret: clientToken, + currency: this.getCurrency(), + mode: 'payment', + amount: this.getTotalPrice() + this.getTotalPrice(), + }); + // this._stripeElements.update({ + // clientSecret: clientToken + // }); + // await this._stripeElements.fetchUpdates(); + } + + event.resolve({}); + } + + /** Utils * */ + + private getCurrency() { + // TODO update currency returning + return 'usd'; + } + + private getTotalPrice(): number { + const { getCheckoutOrThrow, getCartOrThrow } = this.paymentIntegrationService.getState(); + const { decimalPlaces } = getCartOrThrow().currency; + const totalPrice = round(getCheckoutOrThrow().outstandingBalance, decimalPlaces).toFixed( + decimalPlaces, + ); + + return Math.round(+totalPrice * 100); + } + + private async getAvailableShippingOptions(): Promise { + const state = this.paymentIntegrationService.getState(); + const storeConfig = state.getStoreConfigOrThrow(); + const consignments = state.getConsignments(); + + if (!this._currencyService) { + this._currencyService = createCurrencyService(storeConfig); + } + + console.log('consignments', consignments); + + if (!consignments?.[0]) { + // Info: we can not return an empty data because shippingOptions should contain at least one element, it caused a developer exception + return; + } + + const consignment = consignments[0]; + + const availableShippingOptions = (consignment.availableShippingOptions || []).map( + this._getStripeShippingOption.bind(this), + ); + + console.log('availableShippingOptions', availableShippingOptions); + + if (availableShippingOptions.length) { + if (!consignment.selectedShippingOption?.id && availableShippingOptions[0]) { + await this.handleShippingOptionChange(availableShippingOptions[0].id); + } + } + + return availableShippingOptions; + } + + private _getStripeShippingOption({ id, cost, description }: ShippingOption) { + return { + id, + displayName: description, + amount: cost * 100, + }; + } + + private async handleShippingOptionChange(optionId: string) { + if (optionId === 'shipping_option_unselected') { + return; + } + + return this.paymentIntegrationService.selectShippingOption(optionId); + } +} diff --git a/packages/stripe-integration/src/stripe-linkv2/types.ts b/packages/stripe-integration/src/stripe-linkv2/types.ts new file mode 100644 index 0000000000..70cdf66d17 --- /dev/null +++ b/packages/stripe-integration/src/stripe-linkv2/types.ts @@ -0,0 +1,68 @@ +import { + Address, + StripeElement, + StripeElements, + StripeElementType, + StripeStringConstants, + StripeUPEClient, +} from '../stripe-upe/stripe-upe'; + +export type StripeExpressCheckoutElementEvent = 'click' | 'shippingaddresschange' | 'shippingratechange'; + +export interface StripeExpressCheckoutClient extends Omit { + elements(options: StripeExpressCheckoutOptions): StripeExpressCheckoutElements; +} + +export interface StripeExpressCheckoutElements extends Omit { + create( + elementType: StripeElementType, + options?: StripeExpressCheckoutElementCreateOptions, + ): StripeExpressCheckoutElement; + update(options?: StripeExpressCheckoutOptions): StripeExpressCheckoutElement; +} + +export interface StripeExpressCheckoutElement extends Omit { + on(event: StripeExpressCheckoutElementEvent, handler: (event: StripeLinkV2Event) => void): void; + + update(options?: StripeExpressCheckoutElementCreateOptions): void; +} + +export interface StripeExpressCheckoutElementCreateOptions { + paymentMethods?: { + link: StripeStringConstants.AUTO; + applePay: StripeStringConstants.NEVER; + googlePay: StripeStringConstants.NEVER; + amazonPay: StripeStringConstants.NEVER; + paypal: StripeStringConstants.NEVER; + }; + buttonHeight?: number; +} + +export interface StripeLinkV2Event { + address?: Address; + elementType: string; + expressPaymentType: string; + resolve(data: StripeLinkV2EventResolveData): void; +} + +export interface StripeLinkV2EventResolveData { + allowedShippingCountries?: string[]; + shippingAddressRequired?: boolean; + shippingRates?: StripeLinkV2ShippingRates[]; + billingAddressRequired?: boolean; + emailRequired?: boolean; + phoneNumberRequired?: boolean; +} + +export interface StripeLinkV2ShippingRates { + id: string; + amount: number; + displayName: string; +} + +export interface StripeExpressCheckoutOptions { + clientSecret?: string; + mode?: string; + currency?: string; + amount?: number; +} diff --git a/packages/stripe-integration/src/stripe-upe/create-stripe-upe-customer-strategy.ts b/packages/stripe-integration/src/stripe-upe/create-stripe-upe-customer-strategy.ts index 7bddb3d1c6..02c0e4215b 100644 --- a/packages/stripe-integration/src/stripe-upe/create-stripe-upe-customer-strategy.ts +++ b/packages/stripe-integration/src/stripe-upe/create-stripe-upe-customer-strategy.ts @@ -16,5 +16,5 @@ const createStripeUPECustomerStrategy: CustomerStrategyFactory(stripePublishableKey, { stripeAccount, locale, betas: [ @@ -44,6 +46,22 @@ export default class StripeUPEScriptLoader { return stripeClient; } + async getStripeLinkV2Client( + stripePublishableKey: string, + ): Promise { + let stripeClient = this.stripeWindow.bcStripeLinkV2Client; + + if (!stripeClient) { + const stripe = await this.load(); + + stripeClient = stripe(stripePublishableKey); + + Object.assign(this.stripeWindow, { bcStripeLinkV2Client: stripeClient }); + } + + return stripeClient; + } + async getElements( stripeClient: StripeUPEClient, options: StripeElementsOptions, diff --git a/packages/stripe-integration/src/stripe-upe/stripe-upe.ts b/packages/stripe-integration/src/stripe-upe/stripe-upe.ts index 0dce81a80d..39e614ec01 100644 --- a/packages/stripe-integration/src/stripe-upe/stripe-upe.ts +++ b/packages/stripe-integration/src/stripe-upe/stripe-upe.ts @@ -1,3 +1,4 @@ +import { StripeExpressCheckoutClient } from '../stripe-linkv2/types'; import { CustomFont, PaymentIntent, @@ -101,7 +102,7 @@ export interface StripePaymentEvent extends StripeEvent { collapsed?: boolean; } -interface Address { +export interface Address { city: string; country: string; line1: string; @@ -413,8 +414,12 @@ interface StripeUpeResult { export interface StripeHostWindow extends Window { bcStripeClient?: StripeUPEClient; + bcStripeLinkV2Client?: StripeExpressCheckoutClient; bcStripeElements?: StripeElements; - Stripe?(stripePublishableKey: string, options?: StripeConfigurationOptions): StripeUPEClient; + Stripe?( + stripePublishableKey: string, + options?: StripeConfigurationOptions, + ): T; } export enum StripePaymentMethodType { @@ -445,6 +450,7 @@ export enum StripeElementType { PAYMENT = 'payment', AUTHENTICATION = 'linkAuthentication', SHIPPING = 'address', + EXPRESS_CHECKOUT = 'expressCheckout', } export enum StripeUPEPaymentIntentStatus {