Skip to content

Commit

Permalink
feat(payment): STRIPE-448 Stripe LinkV2 created strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-dronov committed Oct 30, 2024
1 parent 67e6e50 commit 9c46451
Show file tree
Hide file tree
Showing 7 changed files with 394 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/stripe-integration/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StripeLinkV2CustomerStrategy> = (
paymentIntegrationService,
) => {
return new StripeLinkV2CustomerStrategy(
paymentIntegrationService,
new StripeUPEScriptLoader(getScriptLoader()),
);
};

export default toResolvableModule(createStripeLinkV2CustomerStrategy, [{ id: 'stripeupe' }]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
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<void> {
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<void> {
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._stripeElements = await this.scriptLoader.getElements(stripeUPEClient, elementsOptions);

this._expressCheckoutElement = this._stripeElements.create(
StripeElementType.EXPRESS_CHECKOUT,
expressCheckoutOptions,
);
this._expressCheckoutElement.mount(`#${container}`);

this._expressCheckoutElement.on('click', async (event: StripeLinkV2Event) =>
this.onClick(event),
);
this._expressCheckoutElement.on('shippingaddresschange', async (event: StripeLinkV2Event) =>
this.onShippingAddressChange(event),
);
}

/** Events * */

private async onClick(event: StripeLinkV2Event) {
if (!('resolve' in event)) {
return;
}

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) {
if (!('resolve' in event)) {
return;
}

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();
const totalPrice = this.getTotalPrice();

if (this._stripeElements) {
this._stripeElements.update({
currency: this.getCurrency(),
mode: 'payment',
amount: Math.round(+totalPrice * 100),
});
}

event.resolve({
shippingRates,
});
}

/** Utils * */

private getCurrency() {
// TODO update currency returning
return 'usd';
}

private getTotalPrice(): string {
const { getCheckoutOrThrow, getCartOrThrow } = this.paymentIntegrationService.getState();
const { decimalPlaces } = getCartOrThrow().currency;
const totalPrice = round(getCheckoutOrThrow().outstandingBalance, decimalPlaces).toFixed(
decimalPlaces,
);

return totalPrice;
}

private async getAvailableShippingOptions(): Promise<StripeLinkV2ShippingRates[] | undefined> {
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);
}
}
73 changes: 73 additions & 0 deletions packages/stripe-integration/src/stripe-linkv2/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
Address,
StripeElement,
StripeElements,
StripeElementType,
StripeStringConstants,
StripeUPEClient,
} from '../stripe-upe/stripe-upe';

export type StripeExpressCheckoutElementEvent = 'click' | 'shippingaddresschange';

export interface StripeExpressCheckoutClient extends Omit<StripeUPEClient, 'elements'> {
elements(options: StripeExpressCheckoutOptions): StripeExpressCheckoutElements;
}

export interface StripeExpressCheckoutElements extends Omit<StripeElements, 'create' | 'update'> {
create(
elementType: StripeElementType,
options?: StripeExpressCheckoutElementCreateOptions,
): StripeExpressCheckoutElement;
update(options?: StripeExpressCheckoutUpdateOptions): StripeExpressCheckoutElement;
}

export interface StripeExpressCheckoutElement extends Omit<StripeElement, 'on' | 'update'> {
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;
}
export interface StripeExpressCheckoutUpdateOptions {
mode?: string;
currency?: string;
amount?: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ const createStripeUPECustomerStrategy: CustomerStrategyFactory<StripeUPECustomer
new StripeUPEScriptLoader(getScriptLoader()),
);
};

export default toResolvableModule(createStripeUPECustomerStrategy, [{ id: 'stripeupe' }]);
// TODO mocking this id
export default toResolvableModule(createStripeUPECustomerStrategy, [{ id: 'stripeupe-test' }]);
Loading

0 comments on commit 9c46451

Please sign in to comment.