Skip to content

Commit 8bad2f4

Browse files
feat(payment): PI-2467 Add "submit" method to the hosted-form-v2 package (#2598)
* feat(payment): PI-2467 Add "submit" method to the hosted-form-v2 package * feat(payment): PI-2467 Add "submit" method to the hosted-form-v2 package * feat(payment): PI-2467 Add "submit" method to the hosted-form-v2 package * feat(payment): PI-2467 Add "submit" method to the hosted-form-v2 package
1 parent 678716a commit 8bad2f4

32 files changed

+860
-11
lines changed

packages/core/src/order/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export {
2525
export { default as OrderState } from './order-state';
2626

2727
export { default as mapToInternalOrder } from './map-to-internal-order';
28+
export { getAwaitingOrder } from './internal-orders.mock';

packages/core/src/payment-integration/create-payment-integration-selectors.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ describe('createPaymentIntegrationSelectors', () => {
101101
expect(() => subject.getStoreConfigOrThrow()).toThrow();
102102
});
103103

104+
it('returns config copy', () => {
105+
const output = subject.getConfig();
106+
107+
expect(output).toEqual(internalSelectors.config.getConfig());
108+
expect(output).not.toBe(internalSelectors.config.getConfig());
109+
});
110+
104111
it('returns copy of consignments', () => {
105112
const output = subject.getConsignments();
106113

@@ -199,6 +206,20 @@ describe('createPaymentIntegrationSelectors', () => {
199206
expect(() => subject.getOrderOrThrow()).toThrow();
200207
});
201208

209+
it('returns copy of order meta', () => {
210+
const output = subject.getOrderMeta();
211+
212+
expect(output).toEqual(internalSelectors.order.getOrderMeta());
213+
expect(output).not.toBe(internalSelectors.order.getOrderMeta());
214+
});
215+
216+
it('returns copy of order meta', () => {
217+
const output = subject.getInstrumentsMeta();
218+
219+
expect(output).toEqual(internalSelectors.instruments.getInstrumentsMeta());
220+
expect(output).not.toBe(internalSelectors.instruments.getInstrumentsMeta());
221+
});
222+
202223
it('returns payment token', () => {
203224
const output = subject.getPaymentToken();
204225

@@ -306,6 +327,13 @@ describe('createPaymentIntegrationSelectors', () => {
306327
expect(() => subject.getPaymentMethodOrThrow('braintree')).toThrow();
307328
});
308329

330+
it('returns copy of payment method meta', () => {
331+
const output = subject.getPaymentMethodsMeta();
332+
333+
expect(output).toEqual(internalSelectors.paymentMethods.getPaymentMethodsMeta());
334+
expect(output).not.toBe(internalSelectors.paymentMethods.getPaymentMethodsMeta());
335+
});
336+
309337
it('returns copy of shipping address', () => {
310338
const output = subject.getShippingAddress();
311339

packages/core/src/payment-integration/create-payment-integration-selectors.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,24 @@ export default function createPaymentIntegrationSelectors({
77
billingAddress: { getBillingAddress, getBillingAddressOrThrow },
88
cart: { getCart, getCartOrThrow },
99
checkout: { getCheckout, getCheckoutOrThrow, getOutstandingBalance },
10-
config: { getContextConfig, getHost, getLocale, getStoreConfig, getStoreConfigOrThrow },
10+
config: {
11+
getContextConfig,
12+
getHost,
13+
getLocale,
14+
getStoreConfig,
15+
getStoreConfigOrThrow,
16+
getConfig,
17+
},
1118
consignments: { getConsignments, getConsignmentsOrThrow },
1219
countries: { getCountries },
1320
customer: { getCustomer, getCustomerOrThrow },
14-
instruments: { getCardInstrument, getCardInstrumentOrThrow, getInstruments },
15-
order: { getOrder, getOrderOrThrow },
21+
instruments: {
22+
getCardInstrument,
23+
getCardInstrumentOrThrow,
24+
getInstruments,
25+
getInstrumentsMeta,
26+
},
27+
order: { getOrder, getOrderOrThrow, getOrderMeta },
1628
payment: {
1729
getPaymentToken,
1830
getPaymentTokenOrThrow,
@@ -24,7 +36,7 @@ export default function createPaymentIntegrationSelectors({
2436
getPaymentRedirectUrlOrThrow,
2537
isPaymentDataRequired,
2638
},
27-
paymentMethods: { getPaymentMethod, getPaymentMethodOrThrow },
39+
paymentMethods: { getPaymentMethod, getPaymentMethodOrThrow, getPaymentMethodsMeta },
2840
paymentProviderCustomer: { getPaymentProviderCustomer, getPaymentProviderCustomerOrThrow },
2941
paymentStrategies: { isInitialized: isPaymentMethodInitialized },
3042
shippingAddress: {
@@ -47,6 +59,7 @@ export default function createPaymentIntegrationSelectors({
4759
getCountries: clone(getCountries),
4860
getStoreConfig: clone(getStoreConfig),
4961
getStoreConfigOrThrow: clone(getStoreConfigOrThrow),
62+
getConfig: clone(getConfig),
5063
getConsignments: clone(getConsignments),
5164
getConsignmentsOrThrow: clone(getConsignmentsOrThrow),
5265
getContextConfig: clone(getContextConfig),
@@ -57,6 +70,8 @@ export default function createPaymentIntegrationSelectors({
5770
getCardInstrumentOrThrow: clone(getCardInstrumentOrThrow),
5871
getOrder: clone(getOrder),
5972
getOrderOrThrow: clone(getOrderOrThrow),
73+
getOrderMeta: clone(getOrderMeta),
74+
getInstrumentsMeta: clone(getInstrumentsMeta),
6075
getPaymentToken,
6176
getPaymentTokenOrThrow,
6277
getPaymentId,
@@ -67,6 +82,7 @@ export default function createPaymentIntegrationSelectors({
6782
getPaymentRedirectUrlOrThrow,
6883
getPaymentMethod: clone(getPaymentMethod),
6984
getPaymentMethodOrThrow: clone(getPaymentMethodOrThrow),
85+
getPaymentMethodsMeta: clone(getPaymentMethodsMeta),
7086
getPaymentProviderCustomer: clone(getPaymentProviderCustomer),
7187
getPaymentProviderCustomerOrThrow: clone(getPaymentProviderCustomerOrThrow),
7288
getShippingAddress: clone(getShippingAddress),

packages/hosted-form-v2/src/hosted-field-events.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import HostedFieldType from './hosted-field-type';
22
import HostedFormManualOrderData from './hosted-form-manual-order-data';
33
import { HostedFieldStylesMap } from './hosted-form-options';
4+
import HostedFormOrderData from './hosted-form-order-data';
45

56
export enum HostedFieldEventType {
67
AttachRequested = 'HOSTED_FIELD:ATTACH_REQUESTED',
8+
SubmitRequested = 'HOSTED_FIELD:SUBMITTED_REQUESTED',
79
SubmitManualOrderRequested = 'HOSTED_FIELD:SUBMIT_MANUAL_ORDER_REQUESTED',
810
ValidateRequested = 'HOSTED_FIELD:VALIDATE_REQUESTED',
911
}
@@ -16,9 +18,18 @@ export interface HostedFieldEventMap {
1618

1719
export type HostedFieldEvent =
1820
| HostedFieldAttachEvent
21+
| HostedFieldSubmitRequestEvent
1922
| HostedFieldSubmitManualOrderRequestEvent
2023
| HostedFieldValidateRequestEvent;
2124

25+
export interface HostedFieldSubmitRequestEvent {
26+
type: HostedFieldEventType.SubmitRequested;
27+
payload: {
28+
data: HostedFormOrderData;
29+
fields: HostedFieldType[];
30+
};
31+
}
32+
2233
export interface HostedFieldAttachEvent {
2334
type: HostedFieldEventType.AttachRequested;
2435
payload: {

packages/hosted-form-v2/src/hosted-field.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { fromEvent } from 'rxjs';
33
import { switchMap, take } from 'rxjs/operators';
44

55
import { DetachmentObserver } from './common/dom';
6+
import { mapFromPaymentErrorResponse } from './common/errors';
67
import { IframeEventListener, IframeEventPoster } from './common/iframe';
78
import { parseUrl } from './common/url';
89
import {
@@ -14,11 +15,14 @@ import { HostedFieldEvent, HostedFieldEventType } from './hosted-field-events';
1415
import HostedFieldType from './hosted-field-type';
1516
import HostedFormManualOrderData from './hosted-form-manual-order-data';
1617
import { HostedFieldStylesMap } from './hosted-form-options';
18+
import HostedFormOrderData from './hosted-form-order-data';
1719
import {
1820
HostedInputEventMap,
1921
HostedInputEventType,
22+
HostedInputSubmitErrorEvent,
2023
HostedInputSubmitManualOrderErrorEvent,
2124
HostedInputSubmitManualOrderSuccessEvent,
25+
HostedInputSubmitSuccessEvent,
2226
HostedInputValidateEvent,
2327
} from './iframe-content';
2428

@@ -109,6 +113,40 @@ export default class HostedField {
109113
this._eventListener.stopListen();
110114
}
111115

116+
async submitForm(
117+
fields: HostedFieldType[],
118+
data: HostedFormOrderData,
119+
): Promise<HostedInputSubmitSuccessEvent> {
120+
try {
121+
const promise = this._eventPoster.post<HostedInputSubmitSuccessEvent>(
122+
{
123+
type: HostedFieldEventType.SubmitRequested,
124+
payload: { fields, data },
125+
},
126+
{
127+
successType: HostedInputEventType.SubmitSucceeded,
128+
errorType: HostedInputEventType.SubmitFailed,
129+
},
130+
);
131+
132+
return await this._detachmentObserver.ensurePresence([this._iframe], promise);
133+
} catch (event) {
134+
if (this._isSubmitErrorEvent(event)) {
135+
if (event.payload.error.code === 'hosted_form_error') {
136+
throw new InvalidHostedFormError(event.payload.error.message);
137+
}
138+
139+
if (event.payload.response) {
140+
throw mapFromPaymentErrorResponse(event.payload.response);
141+
}
142+
143+
throw new Error(event.payload.error.message);
144+
}
145+
146+
throw event;
147+
}
148+
}
149+
112150
async submitManualOrderForm(
113151
data: HostedFormManualOrderData,
114152
): Promise<HostedInputSubmitManualOrderSuccessEvent> {
@@ -184,4 +222,8 @@ export default class HostedField {
184222

185223
return event.type === HostedInputEventType.SubmitManualOrderFailed;
186224
}
225+
226+
private _isSubmitErrorEvent(event: any): event is HostedInputSubmitErrorEvent {
227+
return event.type === HostedInputEventType.SubmitFailed;
228+
}
187229
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { PaymentIntegrationService } from '@bigcommerce/checkout-sdk/payment-integration-api';
2+
import { PaymentIntegrationServiceMock } from '@bigcommerce/checkout-sdk/payment-integrations-test-utils';
3+
4+
import HostedFormOrderDataTransformer from './hosted-form-order-data-transformer';
5+
6+
describe('HostedFormOrderDataTransformer', () => {
7+
let transformer: HostedFormOrderDataTransformer;
8+
let paymentIntegrationService: PaymentIntegrationService;
9+
10+
beforeEach(() => {
11+
paymentIntegrationService = new PaymentIntegrationServiceMock();
12+
transformer = new HostedFormOrderDataTransformer(paymentIntegrationService);
13+
jest.spyOn(paymentIntegrationService.getState(), 'getPaymentToken').mockReturnValue(
14+
'auth-token',
15+
);
16+
17+
// TODO: remove ts-ignore and update test with related type (PAYPAL-4383)
18+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
19+
// @ts-ignore
20+
jest.spyOn(paymentIntegrationService.getState(), 'getInstrumentsMeta').mockReturnValue({
21+
vaultAccessToken: 'vault-token',
22+
});
23+
});
24+
25+
it('transforms payload', () => {
26+
const result = transformer.transform({
27+
methodId: 'authorizenet',
28+
paymentData: { shouldSaveInstrument: true },
29+
});
30+
31+
expect(Object.keys(result)).toEqual(
32+
expect.arrayContaining([
33+
'authToken',
34+
'checkout',
35+
'config',
36+
'order',
37+
'orderMeta',
38+
'payment',
39+
'paymentMethod',
40+
'paymentMethodMeta',
41+
]),
42+
);
43+
});
44+
45+
it('includes vault access token if paying with stored instrument', () => {
46+
const result = transformer.transform({
47+
methodId: 'authorizenet',
48+
paymentData: { instrumentId: '123' },
49+
});
50+
51+
expect(result.authToken).toBe('auth-token, vault-token');
52+
});
53+
54+
it('does not include vault access token if not paying with stored instrument', () => {
55+
const result = transformer.transform({
56+
methodId: 'authorizenet',
57+
paymentData: {
58+
ccExpiry: {
59+
month: '12',
60+
year: '2020',
61+
},
62+
ccName: 'Foo Bar',
63+
ccNumber: '4111 1111 1111 1111',
64+
},
65+
});
66+
67+
expect(result.authToken).toBe('auth-token');
68+
});
69+
70+
it('returns AdditionalAction object within response if received as a parameter', () => {
71+
const additionalActionMock = {
72+
type: 'recaptcha_v2_verification',
73+
data: {
74+
human_verification_token: 'googleRecaptchaToken',
75+
},
76+
};
77+
78+
const result = transformer.transform(
79+
{
80+
methodId: 'authorizenet',
81+
paymentData: { instrumentId: '123' },
82+
},
83+
additionalActionMock,
84+
);
85+
86+
expect(result.additionalAction).toEqual(additionalActionMock);
87+
});
88+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { omit } from 'lodash';
2+
3+
import {
4+
HostedCreditCardInstrument,
5+
isVaultedInstrument,
6+
MissingDataError,
7+
MissingDataErrorType,
8+
OrderPaymentRequestBody,
9+
PaymentAdditionalAction,
10+
PaymentIntegrationService,
11+
} from '@bigcommerce/checkout-sdk/payment-integration-api';
12+
13+
import HostedFormOrderData from './hosted-form-order-data';
14+
15+
export default class HostedFormOrderDataTransformer {
16+
constructor(private paymentIntegrationService: PaymentIntegrationService) {}
17+
18+
transform(
19+
payload: OrderPaymentRequestBody,
20+
additionalAction?: PaymentAdditionalAction,
21+
): HostedFormOrderData {
22+
const state = this.paymentIntegrationService.getState();
23+
const checkout = state.getCheckout();
24+
const config = state.getConfig();
25+
const instrumentMeta = state.getInstrumentsMeta();
26+
const order = state.getOrder();
27+
const orderMeta = state.getOrderMeta();
28+
const payment = omit(
29+
payload.paymentData,
30+
'ccExpiry',
31+
'ccName',
32+
'ccNumber',
33+
'ccCvv',
34+
) as HostedCreditCardInstrument;
35+
const paymentMethod = state.getPaymentMethod(payload.methodId, payload.gatewayId);
36+
const paymentMethodMeta = state.getPaymentMethodsMeta();
37+
const isVaulted = payment && isVaultedInstrument(payment);
38+
const authToken =
39+
isVaulted && instrumentMeta
40+
? `${state.getPaymentToken()}, ${instrumentMeta.vaultAccessToken}`
41+
: state.getPaymentToken();
42+
43+
if (!authToken) {
44+
throw new MissingDataError(MissingDataErrorType.MissingPaymentToken);
45+
}
46+
47+
return {
48+
additionalAction,
49+
authToken,
50+
checkout,
51+
config,
52+
order,
53+
orderMeta,
54+
payment,
55+
paymentMethod,
56+
paymentMethodMeta,
57+
};
58+
}
59+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {
2+
getCheckoutWithGiftCertificates,
3+
getConfig,
4+
getOrder,
5+
getOrderMeta,
6+
getPaymentMethod,
7+
getPaymentMethodsMeta,
8+
} from '@bigcommerce/checkout-sdk/payment-integrations-test-utils';
9+
10+
import HostedFormOrderData from './hosted-form-order-data';
11+
12+
export function getHostedFormOrderData(): HostedFormOrderData {
13+
return {
14+
authToken: 'auth-token',
15+
checkout: getCheckoutWithGiftCertificates(),
16+
config: getConfig(),
17+
order: getOrder(),
18+
orderMeta: getOrderMeta(),
19+
payment: {},
20+
paymentMethod: getPaymentMethod(),
21+
paymentMethodMeta: getPaymentMethodsMeta(),
22+
};
23+
}

0 commit comments

Comments
 (0)