Skip to content

Commit db5610e

Browse files
committed
fix(payment): CHECKOUT-4973 Initialise hosted payment field within its iframe
1 parent 822f65c commit db5610e

24 files changed

+324
-275
lines changed

package-lock.json

Lines changed: 161 additions & 145 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
},
4444
"dependencies": {
4545
"@babel/polyfill": "^7.4.4",
46-
"@bigcommerce/bigpay-client": "^5.7.0",
46+
"@bigcommerce/bigpay-client": "^5.9.0",
4747
"@bigcommerce/data-store": "^1.0.1",
4848
"@bigcommerce/form-poster": "^1.4.0",
4949
"@bigcommerce/memoize": "^1.0.0",

src/hosted-form/hosted-field.spec.ts

Lines changed: 3 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { RequestError } from '../common/error/errors';
22
import { getResponse } from '../common/http-request/responses.mock';
33
import { IframeEventListener, IframeEventPoster } from '../common/iframe';
4-
import { BrowserStorage } from '../common/storage';
54
import { getErrorPaymentResponseBody } from '../payment/payments.mock';
65

76
import { InvalidHostedFormConfigError, InvalidHostedFormError } from './errors';
8-
import HostedField, { LAST_RETRY_KEY } from './hosted-field';
7+
import HostedField from './hosted-field';
98
import { HostedFieldEvent, HostedFieldEventType } from './hosted-field-events';
109
import HostedFieldType from './hosted-field-type';
1110
import { getHostedFormOrderData } from './hosted-form-order-data.mock';
@@ -16,31 +15,23 @@ describe('HostedField', () => {
1615
let field: HostedField;
1716
let eventPoster: Pick<IframeEventPoster<HostedFieldEvent>, 'post' | 'setTarget'>;
1817
let eventListener: Pick<IframeEventListener<HostedInputEventMap>, 'listen'>;
19-
let location: Pick<Location, 'replace'>;
20-
let storage: Pick<BrowserStorage, 'getItem' | 'setItem'>;
2118

2219
beforeEach(() => {
2320
container = document.createElement('div');
2421
eventPoster = { post: jest.fn(), setTarget: jest.fn() };
2522
eventListener = { listen: jest.fn() };
26-
location = { replace: jest.fn() };
27-
storage = { getItem: jest.fn(), setItem: jest.fn() };
2823

2924
container.id = 'field-container-id';
3025
document.body.appendChild(container);
3126

3227
field = new HostedField(
33-
'https://payment.bigcommerce.com',
34-
'dc030783-6129-4ee3-8e06-6f4270df1527',
3528
HostedFieldType.CardNumber,
3629
'field-container-id',
3730
'Enter your card number here',
3831
'Card number',
3932
{ default: { color: 'rgb(0, 0, 0)', fontFamily: 'Open Sans, Arial' } },
4033
eventPoster as IframeEventPoster<HostedFieldEvent>,
41-
eventListener as IframeEventListener<HostedInputEventMap>,
42-
storage as BrowserStorage,
43-
location as Location
34+
eventListener as IframeEventListener<HostedInputEventMap>
4435
);
4536
});
4637

@@ -60,7 +51,7 @@ describe('HostedField', () => {
6051

6152
// tslint:disable-next-line:no-non-null-assertion
6253
expect(document.querySelector<HTMLIFrameElement>('#field-container-id iframe')!.src)
63-
.toEqual('https://payment.bigcommerce.com/pay/hosted_forms/dc030783-6129-4ee3-8e06-6f4270df1527/field?version=1.0.0');
54+
.toEqual(`${location.origin}/checkout/payment/hosted-field?version=1.0.0`);
6455
});
6556

6657
it('sets target for event poster', async () => {
@@ -132,56 +123,6 @@ describe('HostedField', () => {
132123
}, expect.any(Object));
133124
});
134125

135-
it('retries if unable to attach', async () => {
136-
jest.spyOn(eventPoster, 'post')
137-
.mockRejectedValue({
138-
type: HostedInputEventType.AttachFailed,
139-
payload: {
140-
error: { message: 'Invalid form', redirectUrl: 'https://store.foobar.com/checkout' },
141-
},
142-
});
143-
144-
process.nextTick(() => {
145-
// tslint:disable-next-line:no-non-null-assertion
146-
document.querySelector('#field-container-id iframe')!
147-
.dispatchEvent(new Event('load'));
148-
});
149-
150-
field.attach();
151-
152-
// Wait for a timeout because `attach` never resolves in this scenario
153-
await new Promise(resolve => setTimeout(resolve, 1));
154-
155-
expect(location.replace)
156-
.toHaveBeenCalled();
157-
});
158-
159-
it('throws error if unable to attach or retry', async () => {
160-
jest.spyOn(eventPoster, 'post')
161-
.mockRejectedValue({
162-
type: HostedInputEventType.AttachFailed,
163-
payload: {
164-
error: { message: 'Invalid form', redirectUrl: 'https://store.foobar.com/checkout' },
165-
},
166-
});
167-
168-
jest.spyOn(storage, 'getItem')
169-
.mockImplementation(key => key.includes(LAST_RETRY_KEY) ? `${Date.now()}` : undefined);
170-
171-
process.nextTick(() => {
172-
// tslint:disable-next-line:no-non-null-assertion
173-
document.querySelector('#field-container-id iframe')!
174-
.dispatchEvent(new Event('load'));
175-
});
176-
177-
try {
178-
await field.attach();
179-
} catch (error) {
180-
expect(error)
181-
.toBeInstanceOf(InvalidHostedFormError);
182-
}
183-
});
184-
185126
it('throws error if container is invalid', async () => {
186127
container.remove();
187128

src/hosted-form/hosted-field.ts

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { values } from 'lodash';
22
import { fromEvent } from 'rxjs';
3-
import { catchError, switchMap, take } from 'rxjs/operators';
3+
import { switchMap, take } from 'rxjs/operators';
44

55
import { mapFromPaymentErrorResponse } from '../common/error/errors';
66
import { IframeEventListener, IframeEventPoster } from '../common/iframe';
7-
import { BrowserStorage } from '../common/storage';
87
import { parseUrl } from '../common/url';
98
import { CardInstrument } from '../payment/instrument';
109

@@ -13,7 +12,7 @@ import { HostedFieldEvent, HostedFieldEventType } from './hosted-field-events';
1312
import HostedFieldType from './hosted-field-type';
1413
import { HostedFieldStylesMap } from './hosted-form-options';
1514
import HostedFormOrderData from './hosted-form-order-data';
16-
import { HostedInputAttachErrorEvent, HostedInputEventMap, HostedInputEventType, HostedInputSubmitErrorEvent, HostedInputValidateEvent } from './iframe-content';
15+
import { HostedInputEventMap, HostedInputEventType, HostedInputSubmitErrorEvent, HostedInputValidateEvent } from './iframe-content';
1716

1817
export const RETRY_INTERVAL = 60 * 1000;
1918
export const LAST_RETRY_KEY = 'lastRetry';
@@ -22,22 +21,18 @@ export default class HostedField {
2221
private _iframe: HTMLIFrameElement;
2322

2423
constructor(
25-
host: string,
26-
formId: string,
2724
private _type: HostedFieldType,
2825
private _containerId: string,
2926
private _placeholder: string,
3027
private _accessibilityLabel: string,
3128
private _styles: HostedFieldStylesMap,
3229
private _eventPoster: IframeEventPoster<HostedFieldEvent>,
3330
private _eventListener: IframeEventListener<HostedInputEventMap>,
34-
private _storage: BrowserStorage,
35-
private _location: Location,
3631
private _cardInstrument?: CardInstrument
3732
) {
3833
this._iframe = document.createElement('iframe');
3934

40-
this._iframe.src = `${host}/pay/hosted_forms/${formId}/field?version=${LIBRARY_VERSION}`;
35+
this._iframe.src = `/checkout/payment/hosted-field?version=${LIBRARY_VERSION}`;
4136
this._iframe.style.border = 'none';
4237
this._iframe.style.height = '100%';
4338
this._iframe.style.overflow = 'hidden';
@@ -84,13 +79,6 @@ export default class HostedField {
8479
errorType: HostedInputEventType.AttachFailed,
8580
});
8681
}),
87-
catchError(error => {
88-
if (this._isAttachErrorEvent(error)) {
89-
return this._handleAttachErrorEvent(error);
90-
}
91-
92-
throw error;
93-
}),
9482
take(1)
9583
).toPromise();
9684
}
@@ -145,22 +133,6 @@ export default class HostedField {
145133
}
146134
}
147135

148-
private async _handleAttachErrorEvent(event: HostedInputAttachErrorEvent): Promise<void> {
149-
const lastRetry = Number(this._storage.getItem(LAST_RETRY_KEY));
150-
151-
// This is to prevent the possibility of getting into a retry loop, in
152-
// case there is something unexpected that prevents the shopper from
153-
// being able to recover from an invalid hosted payment form error.
154-
if (!lastRetry || Date.now() - lastRetry > RETRY_INTERVAL) {
155-
this._storage.setItem(LAST_RETRY_KEY, Date.now());
156-
this._location.replace(event.payload.error.redirectUrl);
157-
158-
return new Promise(() => {});
159-
}
160-
161-
throw new InvalidHostedFormError(event.payload.error.message);
162-
}
163-
164136
private _getFontUrls(): string[] {
165137
const hostname = 'fonts.googleapis.com';
166138
const links = document.querySelectorAll(`link[href*='${hostname}'][rel='stylesheet']`);
@@ -178,8 +150,4 @@ export default class HostedField {
178150
private _isSubmitErrorEvent(event: any): event is HostedInputSubmitErrorEvent {
179151
return event.type === HostedInputEventType.SubmitFailed;
180152
}
181-
182-
private _isAttachErrorEvent(event: any): event is HostedInputAttachErrorEvent {
183-
return event.type === HostedInputEventType.AttachFailed;
184-
}
185153
}

src/hosted-form/hosted-form-factory.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('HostedFormFactory', () => {
1414
});
1515

1616
it('creates hosted form', () => {
17-
const result = factory.create('https://store.foobar.com', 'dc030783-6129-4ee3-8e06-6f4270df1527', {
17+
const result = factory.create('https://store.foobar.com', {
1818
fields: {
1919
[HostedFieldType.CardCode]: { containerId: 'card-code' },
2020
[HostedFieldType.CardExpiry]: { containerId: 'card-expiry' },

src/hosted-form/hosted-form-factory.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { pick } from 'lodash';
44
import { ReadableCheckoutStore } from '../checkout';
55
import { MissingDataError, MissingDataErrorType } from '../common/error/errors';
66
import { IframeEventListener, IframeEventPoster } from '../common/iframe';
7-
import { BrowserStorage } from '../common/storage';
87
import { CardInstrument } from '../payment/instrument';
98
import { createSpamProtection, PaymentHumanVerificationHandler } from '../spam-protection';
109

@@ -14,14 +13,12 @@ import HostedForm from './hosted-form';
1413
import HostedFormOptions, { HostedCardFieldOptionsMap, HostedStoredCardFieldOptionsMap } from './hosted-form-options';
1514
import HostedFormOrderDataTransformer from './hosted-form-order-data-transformer';
1615

17-
const STORAGE_NAMESPACE = 'BigCommerce.HostedField';
18-
1916
export default class HostedFormFactory {
2017
constructor(
2118
private _store: ReadableCheckoutStore
2219
) {}
2320

24-
create(host: string, formId: string, options: HostedFormOptions): HostedForm {
21+
create(host: string, options: HostedFormOptions): HostedForm {
2522
const fieldTypes = Object.keys(options.fields) as HostedFieldType[];
2623
const fields = fieldTypes.reduce<HostedField[]>((result, type) => {
2724
const fields = options.fields as HostedStoredCardFieldOptionsMap & HostedCardFieldOptionsMap;
@@ -34,17 +31,13 @@ export default class HostedFormFactory {
3431
return [
3532
...result,
3633
new HostedField(
37-
host,
38-
formId,
3934
type,
4035
fieldOptions.containerId,
4136
fieldOptions.placeholder || '',
4237
fieldOptions.accessibilityLabel || '',
4338
options.styles || {},
4439
new IframeEventPoster(host),
4540
new IframeEventListener(host),
46-
new BrowserStorage(STORAGE_NAMESPACE),
47-
window.location,
4841
'instrumentId' in fieldOptions ?
4942
this._getCardInstrument(fieldOptions.instrumentId) :
5043
undefined
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { BrowserStorage } from '../../common/storage';
2+
3+
import HostedInputStorage from './hosted-input-storage';
4+
5+
const STORAGE_NAMESPACE = 'BigCommerce.HostedInput';
6+
7+
let storage: HostedInputStorage | null;
8+
9+
export default function getHostedInputStorage(): HostedInputStorage {
10+
storage = storage || new HostedInputStorage(
11+
new BrowserStorage(STORAGE_NAMESPACE)
12+
);
13+
14+
return storage;
15+
}

src/hosted-form/iframe-content/hosted-input-factory.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import HostedFieldType from '../hosted-field-type';
77

88
import CardExpiryFormatter from './card-expiry-formatter';
99
import CardNumberFormatter from './card-number-formatter';
10+
import getHostedInputStorage from './get-hosted-input-storage';
1011
import HostedAutocompleteFieldset from './hosted-autocomplete-fieldset';
1112
import HostedCardExpiryInput from './hosted-card-expiry-input';
1213
import HostedCardNumberInput from './hosted-card-number-input';
@@ -139,6 +140,7 @@ export default class HostedInputFactory {
139140
return new HostedInputPaymentHandler(
140141
new HostedInputAggregator(window.parent),
141142
new HostedInputValidator(cardInstrument),
143+
getHostedInputStorage(),
142144
new IframeEventPoster(this._parentOrigin, window.parent),
143145
new PaymentRequestSender(createBigpayClient()),
144146
new PaymentRequestTransformer()

src/hosted-form/iframe-content/hosted-input-initializer.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,25 @@ import HostedFieldType from '../hosted-field-type';
77
import HostedInput from './hosted-input';
88
import HostedInputFactory from './hosted-input-factory';
99
import HostedInputInitializer from './hosted-input-initializer';
10+
import HostedInputStorage from './hosted-input-storage';
1011

1112
describe('HostedInputInitializer', () => {
1213
let container: HTMLElement;
1314
let eventListener: IframeEventListener<HostedFieldEventMap>;
1415
let factory: Pick<HostedInputFactory, 'create'>;
1516
let initializer: HostedInputInitializer;
1617
let input: Pick<HostedInput, 'attach'>;
18+
let storage: Pick<HostedInputStorage, 'setNonce'>;
1719

1820
beforeEach(() => {
1921
factory = { create: jest.fn() };
22+
storage = { setNonce: jest.fn() };
2023
eventListener = new IframeEventListener('https://store.foobar.com');
2124
input = { attach: jest.fn() };
2225

2326
initializer = new HostedInputInitializer(
2427
factory as HostedInputFactory,
28+
storage as HostedInputStorage,
2529
eventListener
2630
);
2731

@@ -83,6 +87,20 @@ describe('HostedInputInitializer', () => {
8387
.toHaveBeenCalled();
8488
});
8589

90+
it('stores nonce into storage', async () => {
91+
process.nextTick(() => {
92+
eventListener.trigger({
93+
type: HostedFieldEventType.AttachRequested,
94+
payload: { type: HostedFieldType.CardNumber },
95+
});
96+
});
97+
98+
await initializer.initialize('input-container', 'abc');
99+
100+
expect(storage.setNonce)
101+
.toHaveBeenCalledWith('abc');
102+
});
103+
86104
it('returns newly created input', async () => {
87105
process.nextTick(() => {
88106
eventListener.trigger({

src/hosted-form/iframe-content/hosted-input-initializer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { HostedFieldAttachEvent, HostedFieldEventMap, HostedFieldEventType } fro
77

88
import HostedInput from './hosted-input';
99
import HostedInputFactory from './hosted-input-factory';
10+
import HostedInputStorage from './hosted-input-storage';
1011

1112
interface EventTargetLike<TEvent> {
1213
addListener(eventName: string, handler: (event: TEvent) => void): void;
@@ -16,10 +17,15 @@ interface EventTargetLike<TEvent> {
1617
export default class HostedInputInitializer {
1718
constructor(
1819
private _factory: HostedInputFactory,
20+
private _storage: HostedInputStorage,
1921
private _eventListener: IframeEventListener<HostedFieldEventMap>
2022
) {}
2123

22-
initialize(containerId: string): Promise<HostedInput> {
24+
initialize(containerId: string, nonce?: string): Promise<HostedInput> {
25+
if (nonce) {
26+
this._storage.setNonce(nonce);
27+
}
28+
2329
const form = this._createFormContainer(containerId);
2430

2531
this._resetPageStyles(containerId);

0 commit comments

Comments
 (0)