Commit 563396e6 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'vs/migrate-payment-method-to-gql' into 'master'

Migrate payment method to GraphQL

See merge request gitlab-org/gitlab!64099
parents b21b8337 559ac7b7
<script>
import ProgressBar from 'ee/registrations/components/progress_bar.vue';
import { STEPS, SUBSCRIPTON_FLOW_STEPS } from 'ee/registrations/constants';
import STATE_QUERY from 'ee/subscriptions/graphql/queries/state.query.graphql';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import { s__ } from '~/locale';
import BillingAddress from './checkout/billing_address.vue';
import PaymentMethod from './checkout/payment_method.vue';
import SubscriptionDetails from './checkout/subscription_details.vue';
export default {
components: { ProgressBar, SubscriptionDetails, BillingAddress },
components: { ProgressBar, SubscriptionDetails, BillingAddress, PaymentMethod },
props: {
plans: {
type: Array,
......@@ -16,7 +17,7 @@ export default {
},
apollo: {
isNewUser: {
query: STATE_QUERY,
query: stateQuery,
},
},
currentStep: STEPS.checkout,
......@@ -37,6 +38,7 @@ export default {
<h2 class="gl-mt-6 gl-mb-7 gl-mb-lg-5">{{ $options.i18n.checkout }}</h2>
<subscription-details :plans="plans" />
<billing-address />
<payment-method />
</div>
</div>
</template>
<script>
import { GlSprintf } from '@gitlab/ui';
import { STEPS } from 'ee/subscriptions/constants';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { sprintf, s__ } from '~/locale';
import Zuora from './zuora.vue';
export default {
components: {
GlSprintf,
Step,
Zuora,
},
apollo: {
paymentMethod: {
query: stateQuery,
update: (data) => data.paymentMethod,
},
},
computed: {
isValid() {
return Boolean(this.paymentMethod.id);
},
expirationDate() {
return sprintf(this.$options.i18n.expirationDate, {
expirationMonth: this.paymentMethod.creditCardExpirationMonth,
expirationYear: this.paymentMethod.creditCardExpirationYear.toString(10).slice(-2),
});
},
},
i18n: {
stepTitle: s__('Checkout|Payment method'),
paymentMethod: s__('Checkout|%{cardType} ending in %{lastFourDigits}'),
expirationDate: s__('Checkout|Exp %{expirationMonth}/%{expirationYear}'),
},
stepId: STEPS[2].id,
};
</script>
<template>
<step :step-id="$options.stepId" :title="$options.i18n.stepTitle" :is-valid="isValid">
<template #body="{ active }">
<zuora :active="active" />
</template>
<template #summary>
<div class="js-summary-line-1">
<gl-sprintf :message="$options.i18n.paymentMethod">
<template #cardType>
{{ paymentMethod.creditCardType }}
</template>
<template #lastFourDigits>
<strong>{{ paymentMethod.creditCardMaskNumber.slice(-4) }}</strong>
</template>
</gl-sprintf>
</div>
<div class="js-summary-line-2">
{{ expirationDate }}
</div>
</template>
</step>
</template>
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { pick } from 'lodash';
import Api from 'ee/api';
import {
ERROR_LOADING_PAYMENT_FORM,
ZUORA_SCRIPT_URL,
ZUORA_IFRAME_OVERRIDE_PARAMS,
PAYMENT_FORM_ID,
} from 'ee/subscriptions/constants';
import updateStateMutation from 'ee/subscriptions/graphql/mutations/update_state.mutation.graphql';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import activateNextStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/activate_next_step.mutation.graphql';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
components: {
GlLoadingIcon,
},
props: {
active: {
type: Boolean,
required: true,
},
},
data() {
return {
isLoading: false,
paymentFormParams: null,
zuoraLoaded: false,
zuoraScriptEl: null,
};
},
computed: {
shouldShowZuoraFrame() {
return this.active && this.zuoraLoaded && !this.isLoading;
},
},
mounted() {
this.loadZuoraScript();
},
methods: {
zuoraIframeRendered() {
this.isLoading = false;
this.zuoraLoaded = true;
},
fetchPaymentFormParams() {
this.isLoading = true;
return Api.fetchPaymentFormParams(PAYMENT_FORM_ID)
.then(({ data }) => {
this.paymentFormParams = data;
this.renderZuoraIframe();
})
.catch(() => {
createFlash({ message: ERROR_LOADING_PAYMENT_FORM });
});
},
loadZuoraScript() {
this.isLoading = true;
if (!this.zuoraScriptEl) {
this.zuoraScriptEl = document.createElement('script');
this.zuoraScriptEl.type = 'text/javascript';
this.zuoraScriptEl.async = true;
this.zuoraScriptEl.onload = this.fetchPaymentFormParams;
this.zuoraScriptEl.src = ZUORA_SCRIPT_URL;
document.head.appendChild(this.zuoraScriptEl);
}
},
paymentFormSubmitted({ refId }) {
this.isLoading = true;
return Api.fetchPaymentMethodDetails(refId)
.then(({ data }) => {
return pick(
data,
'id',
'credit_card_expiration_month',
'credit_card_expiration_year',
'credit_card_type',
'credit_card_mask_number',
);
})
.then((paymentMethod) => convertObjectPropsToCamelCase(paymentMethod))
.then((paymentMethod) => this.updateState({ paymentMethod }))
.then(() => this.activateNextStep())
.catch((error) =>
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true }),
)
.finally(() => {
this.isLoading = false;
});
},
renderZuoraIframe() {
const params = { ...this.paymentFormParams, ...ZUORA_IFRAME_OVERRIDE_PARAMS };
window.Z.runAfterRender(this.zuoraIframeRendered);
window.Z.render(params, {}, this.paymentFormSubmitted);
},
activateNextStep() {
return this.$apollo
.mutate({
mutation: activateNextStepMutation,
})
.catch((error) => {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
});
},
updateState(payload) {
return this.$apollo
.mutate({
mutation: updateStateMutation,
variables: {
input: payload,
},
})
.catch((error) => {
createFlash({ message: GENERAL_ERROR_MESSAGE, error, captureError: true });
});
},
},
};
</script>
<template>
<div>
<gl-loading-icon v-if="isLoading" size="lg" />
<div v-show="shouldShowZuoraFrame" id="zuora_payment"></div>
</div>
</template>
......@@ -45,7 +45,7 @@ export default {
},
computed: {
selectedPlan() {
return this.plans.find((plan) => plan.code === this.selectedPlanId);
return this.plans.find((plan) => plan.id === this.selectedPlanId);
},
selectedPlanPrice() {
return this.selectedPlan.pricePerYear;
......@@ -92,6 +92,13 @@ export default {
titleWithName() {
return sprintf(this.$options.i18n.title, { name: this.name });
},
isVisible() {
return (
!this.$apollo.loading &&
(!this.isGroupSelected || this.isSelectedGroupPresent) &&
this.selectedPlan
);
},
},
methods: {
toggleCollapse() {
......@@ -106,7 +113,7 @@ export default {
</script>
<template>
<div
v-if="!$apollo.loading && (!isGroupSelected || isSelectedGroupPresent) && selectedPlan"
v-if="isVisible"
class="order-summary gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-mt-2 mt-lg-5"
>
<div class="d-lg-none">
......
......@@ -26,12 +26,19 @@ export function writeInitialDataToApolloCache(apolloProvider, dataset) {
fullName,
selectedPlanId: planId,
subscription: {
paymentMethodId: null,
quantity: 1,
namespaceId: null,
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Subscription',
},
paymentMethod: {
id: null,
creditCardExpirationMonth: null,
creditCardExpirationYear: null,
creditCardType: null,
creditCardMaskNumber: null,
__typename: 'PaymentMethod',
},
customer: {
country: null,
address1: null,
......
import { s__ } from '~/locale';
export const ZUORA_SCRIPT_URL = 'https://static.zuora.com/Resources/libs/hosted/1.3.1/zuora-min.js';
export const PAYMENT_FORM_ID = 'paid_signup_flow';
export const ZUORA_IFRAME_OVERRIDE_PARAMS = {
style: 'inline',
submitEnabled: 'true',
retainValues: 'true',
};
export const ERROR_FETCHING_COUNTRIES = s__('Checkout|Failed to load countries. Please try again.');
export const ERROR_FETCHING_STATES = s__('Checkout|Failed to load states. Please try again.');
export const ERROR_LOADING_PAYMENT_FORM = s__(
'Checkout|Failed to load the payment form. Please try again.',
);
// The order of the steps in this array determines the flow of the application
/* eslint-disable @gitlab/require-i18n-strings */
......
......@@ -17,8 +17,14 @@ query State {
zipCode
company
}
paymentMethod @client {
id
creditCardExpirationMonth
creditCardExpirationYear
creditCardType
creditCardMaskNumber
}
subscription @client {
paymentMethodId
quantity
namespaceId
}
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { ZUORA_SCRIPT_URL, ZUORA_IFRAME_OVERRIDE_PARAMS } from 'ee/subscriptions/new/constants';
import { ZUORA_SCRIPT_URL, ZUORA_IFRAME_OVERRIDE_PARAMS } from 'ee/subscriptions/constants';
export default {
components: {
......
export const ZUORA_SCRIPT_URL = 'https://static.zuora.com/Resources/libs/hosted/1.3.1/zuora-min.js';
export const PAYMENT_FORM_ID = 'paid_signup_flow';
export const ZUORA_IFRAME_OVERRIDE_PARAMS = {
style: 'inline',
submitEnabled: 'true',
retainValues: 'true',
};
export const TAX_RATE = 0;
export const NEW_GROUP = 'new_group';
import Api from 'ee/api';
import { PAYMENT_FORM_ID } from 'ee/subscriptions/constants';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
import activateNextStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/activate_next_step.mutation.graphql';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
import { PAYMENT_FORM_ID } from '../constants';
import defaultClient from '../graphql';
import * as types from './mutation_types';
......
......@@ -17,7 +17,7 @@ localVue.use(VueApollo);
describe('Order Summary', () => {
const resolvers = { ...purchaseFlowResolvers, ...subscriptionsResolvers };
const initialStateData = {
selectedPlanId: 'silver',
selectedPlanId: 'secondPlanId',
};
let wrapper;
......@@ -106,7 +106,7 @@ describe('Order Summary', () => {
beforeEach(() => {
createComponent({
subscription: { quantity: 1 },
selectedPlanId: 'bronze',
selectedPlanId: 'firstPlanId',
});
});
......@@ -128,7 +128,6 @@ describe('Order Summary', () => {
beforeEach(() => {
createComponent({
subscription: { quantity: 1 },
selectedPlanId: 'silver',
});
});
......@@ -154,7 +153,6 @@ describe('Order Summary', () => {
beforeEach(() => {
createComponent({
subscription: { quantity: 3 },
selectedPlanId: 'silver',
});
});
......@@ -179,7 +177,6 @@ describe('Order Summary', () => {
beforeEach(() => {
createComponent({
subscription: { quantity: 0 },
selectedPlanId: 'silver',
});
});
......
import { mount, createLocalVue } from '@vue/test-utils';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import PaymentMethod from 'ee/subscriptions/buy_minutes/components/checkout/payment_method.vue';
import { resolvers } from 'ee/subscriptions/buy_minutes/graphql/resolvers';
import { STEPS } from 'ee/subscriptions/constants';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
import { stateData as initialStateData } from 'ee_jest/subscriptions/buy_minutes/mock_data';
import { createMockApolloProvider } from 'ee_jest/vue_shared/purchase_flow/spec_helper';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Payment Method', () => {
let wrapper;
const createComponent = (apolloLocalState = {}) => {
const apolloProvider = createMockApolloProvider(STEPS, STEPS[2], {
...resolvers,
});
apolloProvider.clients.defaultClient.cache.writeQuery({
query: stateQuery,
data: merge({}, initialStateData, apolloLocalState),
});
return mount(PaymentMethod, {
localVue,
apolloProvider,
});
};
beforeEach(() => {
wrapper = createComponent({
paymentMethod: {
id: 'paymentMethodId',
creditCardType: 'Visa',
creditCardMaskNumber: '************4242',
creditCardExpirationMonth: 12,
creditCardExpirationYear: 2009,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('validations', () => {
const isStepValid = () => wrapper.find(Step).props('isValid');
it('should be valid when paymentMethodId is defined', () => {
expect(isStepValid()).toBe(true);
});
it('should be invalid when paymentMethodId is undefined', () => {
wrapper = createComponent({
paymentMethod: { id: null },
});
expect(isStepValid()).toBe(false);
});
});
describe('showing the summary', () => {
it('should show the entered credit card details', () => {
expect(wrapper.find('.js-summary-line-1').text()).toMatchInterpolatedText(
'Visa ending in 4242',
);
});
it('should show the entered credit card expiration date', () => {
expect(wrapper.find('.js-summary-line-2').text()).toBe('Exp 12/09');
});
});
});
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import Zuora from 'ee/subscriptions/buy_minutes/components/checkout/zuora.vue';
import { resolvers } from 'ee/subscriptions/buy_minutes/graphql/resolvers';
import { STEPS } from 'ee/subscriptions/constants';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import { stateData as initialStateData } from 'ee_jest/subscriptions/buy_minutes/mock_data';
import { createMockApolloProvider } from 'ee_jest/vue_shared/purchase_flow/spec_helper';
import axios from '~/lib/utils/axios_utils';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Zuora', () => {
let axiosMock;
let wrapper;
const createComponent = (props = {}, data = {}, apolloLocalState = {}) => {
const apolloProvider = createMockApolloProvider(STEPS, STEPS[1], {
...resolvers,
});
apolloProvider.clients.defaultClient.cache.writeQuery({
query: stateQuery,
data: merge({}, initialStateData, apolloLocalState),
});
return shallowMount(Zuora, {
propsData: {
active: true,
...props,
},
data() {
return { ...data };
},
localVue,
});
};
const findLoading = () => wrapper.find(GlLoadingIcon);
const findZuoraPayment = () => wrapper.find('#zuora_payment');
beforeEach(() => {
window.Z = {
runAfterRender(fn) {
return Promise.resolve().then(fn);
},
render() {},
};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`/-/subscriptions/payment_form`).reply(200, {});
});
afterEach(() => {
delete window.Z;
wrapper.destroy();
});
describe('when active', () => {
beforeEach(async () => {
wrapper = createComponent({}, { isLoading: false });
});
it('shows the loading icon', () => {
expect(findLoading().exists()).toBe(true);
});
it('the zuora_payment selector should be hidden', () => {
expect(findZuoraPayment().isVisible()).toBe(false);
});
describe('when toggling the loading indicator', () => {
beforeEach(() => {
wrapper = createComponent({}, { isLoading: true });
wrapper.vm.zuoraScriptEl.onload();
});
it('shows the loading icon', () => {
expect(findLoading().exists()).toBe(true);
});
it('the zuora_payment selector should not be visible', () => {
expect(findZuoraPayment().isVisible()).toBe(false);
});
});
});
describe('when not active', () => {
beforeEach(() => {
wrapper = createComponent({ active: false });
});
it('the zuora_payment selector should not be visible', () => {
expect(findZuoraPayment().isVisible()).toBe(false);
});
});
});
......@@ -21,10 +21,17 @@ export const stateData = {
subscription: {
quantity: 1,
namespaceId: null,
paymentMethodId: null,
__typename: 'Subscription',
},
selectedPlanId: null,
paymentMethod: {
id: null,
creditCardExpirationMonth: null,
creditCardExpirationYear: null,
creditCardType: null,
creditCardMaskNumber: null,
__typename: 'PaymentMethod',
},
customer: {
country: null,
address1: null,
......
import MockAdapter from 'axios-mock-adapter';
import Api from 'ee/api';
import * as constants from 'ee/subscriptions/new/constants';
import * as constants from 'ee/subscriptions/constants';
import defaultClient from 'ee/subscriptions/new/graphql';
import * as actions from 'ee/subscriptions/new/store/actions';
import { GENERAL_ERROR_MESSAGE } from 'ee/vue_shared/purchase_flow/constants';
......
......@@ -6355,6 +6355,9 @@ msgstr ""
msgid "Checkout|Failed to load states. Please try again."
msgstr ""
msgid "Checkout|Failed to load the payment form. Please try again."
msgstr ""
msgid "Checkout|Failed to register credit card. Please try again."
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment