diff --git a/README.md b/README.md index b408bacc..7d87c9f4 100644 --- a/README.md +++ b/README.md @@ -931,8 +931,9 @@ Integration flow: - The `headlessCheckout.form.init` method returns the form object that can be used for future work with the payment form. 1. Subscribe to events of the `NextActions` form to receive notifications about the next payment flow steps: - The `NextAction` with the `show_fields` type means that the form needs to render extra fields, e.g., for Brazilian credit cards. You must remove previously added fields and render new fields for this step. - - **3DS 1.0 (a deprecated method of payment authorization)**: The Next action with the `redirect` type means the form is redirected to complete payment according to the **3DS 1.0** secure procedure. See also [Special cases for 3DS 1.0](#special-cases-for-3ds-10). - - **3DS 2.0**: The `NextAction` with the `3DS` type means the user’s card must be verified according to the **3DS 2.0** procedure. When an additional user action (the challenge flow) is required, the `3DS` event is triggered along with the `data` object. Pass this object to the `ThreeDs` component as the `data-challenge` attribute (refer to the [example](./examples/credit-card)). The component opens a new browser tab where the user can authorize the payment. + - 3-D Secure verification can be handled either by the acquirer's built-in mechanism or through an external [MPI](https://en.wikipedia.org/wiki/Merchant_plug-in), which authenticates a cardholder and forwards the result to the acquirer. + - **3DS via external MPI**: The Next action with the `redirect` type means the form is redirected to complete payment according to the 3DS procedure. See also [Special cases for 3DS via external MPI](#special-cases-for-3ds-via-external-mpi). + - **3DS via built-in method**: The `NextAction` with the `3DS` type means the user's card must be verified according to the procedure triggered by acquirer. When an additional user action (the challenge flow) is required, the `3DS` event is triggered along with the `data` object. Pass this object to the `ThreeDs` component as the `data-challenge` attribute (refer to the [example](./examples/credit-card)). The component opens a new browser tab where the user can authorize the payment. 1. Add the form fields component to the HTML markup. - Use the form object that was returned by `headlessCheckout.form.init` method to get form fields. - Use fields with the `isMandatory` flag to get required fields only. @@ -943,9 +944,9 @@ Integration flow: 1. Add the ``, `` and `` components to the created `return` page to show a payment status. 1. Set accessToken at `headlessCheckout.setToken`. Run `headlessCheckout.init` to initialize the headless checkout library. -### Special cases for 3DS 1.0 +### Special cases for 3DS via external MPI -If you embed the payment interface in an iframe, be aware that some ACS providers implement the 3DS 1.0 challenge flow by performing an additional redirect using: +If you embed the payment interface in an iframe, be aware that some ACS providers implement the 3DS challenge flow by performing an additional redirect using: ```js window.top.location.href = 'redirect_url'; diff --git a/src/core/environment/environment.service.ts b/src/core/environment/environment.service.ts new file mode 100644 index 00000000..15e6c09a --- /dev/null +++ b/src/core/environment/environment.service.ts @@ -0,0 +1,25 @@ +import { singleton } from 'tsyringe'; +import { + headlessCheckoutAppUrl, + headlessCheckoutSandboxAppUrl, +} from '../../features/headless-checkout/environment'; + +@singleton() +export class EnvironmentService { + private _isSandbox = false; + + public set isSandbox(value: boolean) { + this._isSandbox = value; + } + + public get isSandbox(): boolean { + return this._isSandbox; + } + + public getHeadlessCheckoutAppUrl(): string { + return this._isSandbox + ? headlessCheckoutSandboxAppUrl + : headlessCheckoutAppUrl; + } +} + diff --git a/src/core/web-components/secure-component/secure-component.abstract.ts b/src/core/web-components/secure-component/secure-component.abstract.ts index fa8e35d2..fb23a0ca 100644 --- a/src/core/web-components/secure-component/secure-component.abstract.ts +++ b/src/core/web-components/secure-component/secure-component.abstract.ts @@ -1,14 +1,22 @@ +import { container } from 'tsyringe'; import { WebComponentAbstract } from '../web-component.abstract'; -import { headlessCheckoutAppUrl } from '../../../features/headless-checkout/environment'; +import { EnvironmentService } from '../../environment/environment.service'; export abstract class SecureComponentAbstract extends WebComponentAbstract { protected componentName: string | null = null; + protected environmentService: EnvironmentService; + + protected constructor() { + super(); + this.environmentService = container.resolve(EnvironmentService); + } protected getSecureHtml(): string { if (!this.componentName) { throw new Error('Component name is required'); } - return ``; + const appUrl = this.environmentService.getHeadlessCheckoutAppUrl(); + return ``; } } diff --git a/src/features/headless-checkout/environment.ts b/src/features/headless-checkout/environment.ts index c4d845a6..85348cdc 100644 --- a/src/features/headless-checkout/environment.ts +++ b/src/features/headless-checkout/environment.ts @@ -1,5 +1,8 @@ export const headlessCheckoutAppUrl = 'https://secure.xsolla.com/headless-checkout'; +export const headlessCheckoutSandboxAppUrl = + 'https://sandbox-secure.xsolla.com/headless-checkout'; + export const cdnIconsUrl = 'https://cdn.xsolla.net/headless-checkout-prod/assets/icons'; diff --git a/src/features/headless-checkout/headless-checkout.spec.ts b/src/features/headless-checkout/headless-checkout.spec.ts index 5ae51dc2..ec356381 100644 --- a/src/features/headless-checkout/headless-checkout.spec.ts +++ b/src/features/headless-checkout/headless-checkout.spec.ts @@ -10,6 +10,7 @@ import { FormStatus } from '../../core/status/form-status.enum'; import { noopStub } from '../../tests/stubs/noop.stub'; import { headlessCheckoutAppUrl } from './environment'; import { ThemesLoader } from '../../core/customization/themes-loader'; +import { EnvironmentService } from '../../core/environment/environment.service'; import { submitHandler } from './post-messages-handlers/submit/submit.handler'; const mockMessage: Message = { @@ -43,6 +44,7 @@ describe('HeadlessCheckout', () => { let postMessagesClient: PostMessagesClient; let localizeService: LocalizeService; let themesLoader: ThemesLoader; + let environmentService: EnvironmentService; // eslint-disable-next-line @typescript-eslint/no-empty-function const stub = (): void => {}; @@ -71,6 +73,11 @@ describe('HeadlessCheckout', () => { getTheme: () => 'body { margin: 0;}', } as unknown as ThemesLoader; + environmentService = { + isSandbox: false, + getHeadlessCheckoutAppUrl: () => headlessCheckoutAppUrl, + } as unknown as EnvironmentService; + container.clearInstances(); headlessCheckout = container @@ -85,6 +92,9 @@ describe('HeadlessCheckout', () => { .register(ThemesLoader, { useValue: themesLoader, }) + .register(EnvironmentService, { + useValue: environmentService, + }) .registerSingleton(HeadlessCheckout) .resolve(HeadlessCheckout); }); diff --git a/src/features/headless-checkout/headless-checkout.ts b/src/features/headless-checkout/headless-checkout.ts index 8d83d6a6..fb9e14a9 100644 --- a/src/features/headless-checkout/headless-checkout.ts +++ b/src/features/headless-checkout/headless-checkout.ts @@ -24,7 +24,7 @@ import { getUserBalanceHandler } from './post-messages-handlers/get-user-balance import { nextActionHandler } from './post-messages-handlers/next-action.handler'; import { Field } from '../../core/form/field.interface'; import { getPaymentStatusHandler } from './post-messages-handlers/get-payment-status/get-payment-status.handler'; -import { headlessCheckoutAppUrl } from './environment'; +import { EnvironmentService } from '../../core/environment/environment.service'; import { FinanceDetails } from '../../core/finance-details/finance-details.interface'; import { getFinanceDetailsHandler } from './post-messages-handlers/get-finance-details.handler'; import { FormStatus } from '../../core/status/form-status.enum'; @@ -176,14 +176,12 @@ export class HeadlessCheckout { private formStatus: FormStatus = FormStatus.undefined; private isWebView?: boolean; - private isSandbox?: boolean; private theme?: string; private topLevelDomain?: string; private isApplePayInstantFlowEnabled = false; private locale = Lang.EN; private coreIframe!: HTMLIFrameElement; private errorsSubscription?: () => void; - private readonly headlessAppUrl = headlessCheckoutAppUrl; private _formConfiguration?: FormConfiguration; public constructor( @@ -194,11 +192,12 @@ export class HeadlessCheckout { private readonly formSpy: FormSpy, private readonly themesLoader: ThemesLoader, private readonly formLoader: FormLoader, + private readonly environmentService: EnvironmentService, ) {} public async init(environment: InitialOptions): Promise { this.isWebView = environment.isWebview; - this.isSandbox = environment.sandbox; + this.environmentService.isSandbox = !!environment.sandbox; this.theme = environment.theme; this.topLevelDomain = environment.topLevelDomain; this.isApplePayInstantFlowEnabled = @@ -207,11 +206,17 @@ export class HeadlessCheckout { await this.localizeService.initDictionaries(environment.language); - this.postMessagesClient.init(this.coreIframe, this.headlessAppUrl); + this.postMessagesClient.init( + this.coreIframe, + this.environmentService.getHeadlessCheckoutAppUrl(), + ); await this.setupCoreIframe(); this.defineComponents(); - this.postMessagesClient.init(this.coreIframe, this.headlessAppUrl); + this.postMessagesClient.init( + this.coreIframe, + this.environmentService.getHeadlessCheckoutAppUrl(), + ); void this.setupSecureStyles(this.theme); this.errorsSubscription = this.postMessagesClient.listen( EventName.error, @@ -245,7 +250,7 @@ export class HeadlessCheckout { configuration: { token, isWebView: this.isWebView, - sandbox: this.isSandbox, + sandbox: this.environmentService.isSandbox, topLevelDomain: this.topLevelDomain, isApplePayInstantFlowEnabled: this.isApplePayInstantFlowEnabled, locale: this.locale, @@ -443,7 +448,7 @@ export class HeadlessCheckout { this.coreIframe.height = '0px'; this.coreIframe.style.border = 'none'; this.coreIframe.style.position = 'absolute'; - this.coreIframe.src = `${this.headlessAppUrl}/core`; + this.coreIframe.src = `${this.environmentService.getHeadlessCheckoutAppUrl()}/core`; this.coreIframe.name = 'core'; this.window.document.body.appendChild(this.coreIframe); return this.listenCoreIframeLoading(); diff --git a/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts b/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts index 23a594e3..d3de633f 100644 --- a/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts +++ b/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts @@ -9,7 +9,6 @@ import { applePayErrors } from './apple-pay.errors.const'; import i18next from 'i18next'; import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; import { finishLoadComponentHandler } from '../../post-messages-handlers/finish-load-component.handler'; -import { headlessCheckoutAppUrl } from '../../environment'; import { openApplePayPageHandler } from '../../post-messages-handlers/apple-pay/open-apple-pay-page.handler'; import { getWaitingProcessingTemplate } from './waiting-processing.template'; import { waitingProcessingClassname } from './waiting-processing-classname.const'; @@ -160,7 +159,8 @@ export class ApplePayComponent extends SecureComponentAbstract { } protected getSecureHtml(): string { - return ``; } diff --git a/src/features/headless-checkout/web-components/pages/google-pay/google-pay-button.component.ts b/src/features/headless-checkout/web-components/pages/google-pay/google-pay-button.component.ts index bd960094..ac2a6430 100644 --- a/src/features/headless-checkout/web-components/pages/google-pay/google-pay-button.component.ts +++ b/src/features/headless-checkout/web-components/pages/google-pay/google-pay-button.component.ts @@ -1,5 +1,4 @@ import { SecureComponentAbstract } from '../../../../../core/web-components/secure-component/secure-component.abstract'; -import { headlessCheckoutAppUrl } from '../../../environment'; import { GooglePayButtonColorType } from './google-pay-button-color.type'; import { EventName } from '../../../../../core/event-name.enum'; import { container } from 'tsyringe'; @@ -93,6 +92,7 @@ export class GooglePayButtonComponent extends SecureComponentAbstract { } protected getSecureHtml(): string { - return ``; + const appUrl = this.environmentService.getHeadlessCheckoutAppUrl(); + return ``; } } diff --git a/src/features/headless-checkout/web-components/phone-component/phone.component.ts b/src/features/headless-checkout/web-components/phone-component/phone.component.ts index 86c64995..9732ba45 100644 --- a/src/features/headless-checkout/web-components/phone-component/phone.component.ts +++ b/src/features/headless-checkout/web-components/phone-component/phone.component.ts @@ -1,5 +1,4 @@ import { PhoneComponentAttributes } from './phone-component-attributes.enum'; -import { headlessCheckoutAppUrl } from '../../environment'; import { TextComponent } from '../text-component/text.component'; export class PhoneComponent extends TextComponent { @@ -19,7 +18,8 @@ export class PhoneComponent extends TextComponent { throw new Error('Component name is required'); } - let src = `${headlessCheckoutAppUrl}/secure-components/${this.componentName}`; + const appUrl = this.environmentService.getHeadlessCheckoutAppUrl(); + let src = `${appUrl}/secure-components/${this.componentName}`; const showFlags = this.getAttribute(PhoneComponentAttributes.showFlags); if (showFlags) { diff --git a/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts index 15e4f4c0..748e620a 100644 --- a/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts +++ b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts @@ -12,7 +12,6 @@ import { isEventMessage } from '../../../../core/guards/event-message.guard'; import { CashPaymentData } from '../../../../core/cash-payment-data.interface'; import { getCashPaymentDataHandler } from '../../post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler'; import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client'; -import { headlessCheckoutAppUrl } from '../../environment'; import { getXsollaNumberComponentTemplate } from './templates/get-xsolla-number.compontent.template'; import './xsolla-number.component.scss'; import { SendButtonStatus } from './send-button-status.interface'; @@ -56,8 +55,9 @@ export class XsollaNumberComponent extends SecureComponentAbstract { } protected getHtml(): string { - const emailControl = ``; - const phoneControl = ``; + const appUrl = this.environmentService.getHeadlessCheckoutAppUrl(); + const emailControl = ``; + const phoneControl = ``; const title = this.cashPaymentData?.title; const userName = this.cashPaymentData?.publicId; const xsollaNumber = this.cashPaymentData?.xsollaNumber;