Commit f106d99b authored by Alex Buijs's avatar Alex Buijs

Add confirm order component for paid signup flow

This is part of the paid signup flow
parent 87c0d8bf
...@@ -23,6 +23,11 @@ export default { ...@@ -23,6 +23,11 @@ export default {
cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id', cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id',
cycleAnalyticsDurationChartPath: '/-/analytics/cycle_analytics/stages/:stage_id/duration_chart', cycleAnalyticsDurationChartPath: '/-/analytics/cycle_analytics/stages/:stage_id/duration_chart',
codeReviewAnalyticsPath: '/api/:version/analytics/code_review', codeReviewAnalyticsPath: '/api/:version/analytics/code_review',
countriesPath: '/-/countries',
countryStatesPath: '/-/country_states',
paymentFormPath: '/-/subscriptions/payment_form',
paymentMethodPath: '/-/subscriptions/payment_method',
confirmOrderPath: '/-/subscriptions',
userSubscription(namespaceId) { userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
...@@ -231,4 +236,29 @@ export default { ...@@ -231,4 +236,29 @@ export default {
const url = Api.buildUrl(this.geoDesignsPath); const url = Api.buildUrl(this.geoDesignsPath);
return axios.put(`${url}/${projectId}/${action}`, {}); return axios.put(`${url}/${projectId}/${action}`, {});
}, },
fetchCountries() {
const url = Api.buildUrl(this.countriesPath);
return axios.get(url);
},
fetchStates(country) {
const url = Api.buildUrl(this.countryStatesPath);
return axios.get(url, { params: { country } });
},
fetchPaymentFormParams(id) {
const url = Api.buildUrl(this.paymentFormPath);
return axios.get(url, { params: { id } });
},
fetchPaymentMethodDetails(id) {
const url = Api.buildUrl(this.paymentMethodPath);
return axios.get(url, { params: { id } });
},
confirmOrder(params = {}) {
const url = Api.buildUrl(this.confirmOrderPath);
return axios.post(url, params);
},
}; };
...@@ -4,9 +4,10 @@ import ProgressBar from './checkout/progress_bar.vue'; ...@@ -4,9 +4,10 @@ import ProgressBar from './checkout/progress_bar.vue';
import SubscriptionDetails from './checkout/subscription_details.vue'; import SubscriptionDetails from './checkout/subscription_details.vue';
import BillingAddress from './checkout/billing_address.vue'; import BillingAddress from './checkout/billing_address.vue';
import PaymentMethod from './checkout/payment_method.vue'; import PaymentMethod from './checkout/payment_method.vue';
import ConfirmOrder from './checkout/confirm_order.vue';
export default { export default {
components: { ProgressBar, SubscriptionDetails, BillingAddress, PaymentMethod }, components: { ProgressBar, SubscriptionDetails, BillingAddress, PaymentMethod, ConfirmOrder },
i18n: { i18n: {
checkout: s__('Checkout|Checkout'), checkout: s__('Checkout|Checkout'),
}, },
...@@ -22,5 +23,6 @@ export default { ...@@ -22,5 +23,6 @@ export default {
<billing-address /> <billing-address />
<payment-method /> <payment-method />
</div> </div>
<confirm-order />
</div> </div>
</template> </template>
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlButton,
GlLoadingIcon,
},
computed: {
...mapState(['isConfirmingOrder']),
...mapGetters(['currentStep']),
isActive() {
return this.currentStep === 'confirmOrder';
},
},
methods: {
...mapActions(['confirmOrder']),
},
i18n: {
confirm: s__('Checkout|Confirm purchase'),
confirming: s__('Checkout|Confirming...'),
},
};
</script>
<template>
<div v-if="isActive" class="full-width prepend-bottom-32">
<gl-button :disabled="isConfirmingOrder" variant="success" @click="confirmOrder">
<gl-loading-icon v-if="isConfirmingOrder" inline size="sm" />
{{ isConfirmingOrder ? $options.i18n.confirming : $options.i18n.confirm }}
</gl-button>
</div>
</template>
// The order of the steps in this array determines the flow of the application // The order of the steps in this array determines the flow of the application
export const STEPS = ['subscriptionDetails', 'billingAddress', 'paymentMethod']; export const STEPS = ['subscriptionDetails', 'billingAddress', 'paymentMethod', 'confirmOrder'];
export const COUNTRIES_URL = '/-/countries';
export const STATES_URL = '/-/country_states';
export const ZUORA_SCRIPT_URL = 'https://static.zuora.com/Resources/libs/hosted/1.3.1/zuora-min.js'; export const ZUORA_SCRIPT_URL = 'https://static.zuora.com/Resources/libs/hosted/1.3.1/zuora-min.js';
export const PAYMENT_FORM_URL = '/-/subscriptions/payment_form';
export const PAYMENT_FORM_ID = 'paid_signup_flow'; export const PAYMENT_FORM_ID = 'paid_signup_flow';
export const PAYMENT_METHOD_URL = '/-/subscriptions/payment_method';
export const ZUORA_IFRAME_OVERRIDE_PARAMS = { export const ZUORA_IFRAME_OVERRIDE_PARAMS = {
style: 'inline', style: 'inline',
submitEnabled: 'true', submitEnabled: 'true',
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { sprintf, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { import Api from 'ee/api';
STEPS, import { redirectTo } from '~/lib/utils/url_utility';
COUNTRIES_URL, import { STEPS, PAYMENT_FORM_ID } from '../constants';
STATES_URL,
PAYMENT_FORM_URL,
PAYMENT_FORM_ID,
PAYMENT_METHOD_URL,
} from '../constants';
export const activateStep = ({ commit }, currentStep) => { export const activateStep = ({ commit }, currentStep) => {
if (STEPS.includes(currentStep)) { if (STEPS.includes(currentStep)) {
...@@ -44,8 +38,7 @@ export const updateOrganizationName = ({ commit }, organizationName) => { ...@@ -44,8 +38,7 @@ export const updateOrganizationName = ({ commit }, organizationName) => {
}; };
export const fetchCountries = ({ dispatch }) => export const fetchCountries = ({ dispatch }) =>
axios Api.fetchCountries()
.get(COUNTRIES_URL)
.then(({ data }) => dispatch('fetchCountriesSuccess', data)) .then(({ data }) => dispatch('fetchCountriesSuccess', data))
.catch(() => dispatch('fetchCountriesError')); .catch(() => dispatch('fetchCountriesError'));
...@@ -66,8 +59,7 @@ export const fetchStates = ({ state, dispatch }) => { ...@@ -66,8 +59,7 @@ export const fetchStates = ({ state, dispatch }) => {
return; return;
} }
axios Api.fetchStates(state.country)
.get(STATES_URL, { params: { country: state.country } })
.then(({ data }) => dispatch('fetchStatesSuccess', data)) .then(({ data }) => dispatch('fetchStatesSuccess', data))
.catch(() => dispatch('fetchStatesError')); .catch(() => dispatch('fetchStatesError'));
}; };
...@@ -115,8 +107,7 @@ export const startLoadingZuoraScript = ({ commit }) => ...@@ -115,8 +107,7 @@ export const startLoadingZuoraScript = ({ commit }) =>
commit(types.UPDATE_IS_LOADING_PAYMENT_METHOD, true); commit(types.UPDATE_IS_LOADING_PAYMENT_METHOD, true);
export const fetchPaymentFormParams = ({ dispatch }) => export const fetchPaymentFormParams = ({ dispatch }) =>
axios Api.fetchPaymentFormParams(PAYMENT_FORM_ID)
.get(PAYMENT_FORM_URL, { params: { id: PAYMENT_FORM_ID } })
.then(({ data }) => dispatch('fetchPaymentFormParamsSuccess', data)) .then(({ data }) => dispatch('fetchPaymentFormParamsSuccess', data))
.catch(() => dispatch('fetchPaymentFormParamsError')); .catch(() => dispatch('fetchPaymentFormParamsError'));
...@@ -167,8 +158,7 @@ export const paymentFormSubmittedError = (_, response) => { ...@@ -167,8 +158,7 @@ export const paymentFormSubmittedError = (_, response) => {
}; };
export const fetchPaymentMethodDetails = ({ state, dispatch, commit }) => export const fetchPaymentMethodDetails = ({ state, dispatch, commit }) =>
axios Api.fetchPaymentMethodDetails(state.paymentMethodId)
.get(PAYMENT_METHOD_URL, { params: { id: state.paymentMethodId } })
.then(({ data }) => dispatch('fetchPaymentMethodDetailsSuccess', data)) .then(({ data }) => dispatch('fetchPaymentMethodDetailsSuccess', data))
.catch(() => dispatch('fetchPaymentMethodDetailsError')) .catch(() => dispatch('fetchPaymentMethodDetailsError'))
.finally(() => commit(types.UPDATE_IS_LOADING_PAYMENT_METHOD, false)); .finally(() => commit(types.UPDATE_IS_LOADING_PAYMENT_METHOD, false));
...@@ -182,3 +172,28 @@ export const fetchPaymentMethodDetailsSuccess = ({ commit, dispatch }, creditCar ...@@ -182,3 +172,28 @@ export const fetchPaymentMethodDetailsSuccess = ({ commit, dispatch }, creditCar
export const fetchPaymentMethodDetailsError = () => { export const fetchPaymentMethodDetailsError = () => {
createFlash(s__('Checkout|Failed to register credit card. Please try again.')); createFlash(s__('Checkout|Failed to register credit card. Please try again.'));
}; };
export const confirmOrder = ({ getters, dispatch, commit }) => {
commit(types.UPDATE_IS_CONFIRMING_ORDER, true);
Api.confirmOrder(getters.confirmOrderParams)
.then(({ data }) => {
if (data.location) dispatch('confirmOrderSuccess', data.location);
else dispatch('confirmOrderError', JSON.stringify(data.errors));
})
.catch(() => dispatch('confirmOrderError'));
};
export const confirmOrderSuccess = (_, location) => {
redirectTo(location);
};
export const confirmOrderError = ({ commit }, message = null) => {
commit(types.UPDATE_IS_CONFIRMING_ORDER, false);
const errorString = message
? s__('Checkout|Failed to confirm your order: %{message}. Please try again.')
: s__('Checkout|Failed to confirm your order! Please try again.');
createFlash(sprintf(errorString, { message }, false));
};
...@@ -15,6 +15,24 @@ export const selectedPlanPrice = (state, getters) => ...@@ -15,6 +15,24 @@ export const selectedPlanPrice = (state, getters) =>
export const selectedPlanDetails = state => export const selectedPlanDetails = state =>
state.availablePlans.find(plan => plan.value === state.selectedPlan); state.availablePlans.find(plan => plan.value === state.selectedPlan);
export const confirmOrderParams = state => ({
setup_for_company: state.isSetupForCompany,
customer: {
country: state.country,
address_1: state.streetAddressLine1,
address_2: state.streetAddressLine2,
city: state.city,
state: state.countryState,
zip_code: state.zipCode,
company: state.organizationName,
},
subscription: {
plan_id: state.selectedPlan,
payment_method_id: state.paymentMethodId,
quantity: state.numberOfUsers,
},
});
export const endDate = state => export const endDate = state =>
new Date(state.startDate).setFullYear(state.startDate.getFullYear() + 1); new Date(state.startDate).setFullYear(state.startDate.getFullYear() + 1);
......
...@@ -31,3 +31,5 @@ export const UPDATE_PAYMENT_METHOD_ID = 'UPDATE_PAYMENT_METHOD_ID'; ...@@ -31,3 +31,5 @@ export const UPDATE_PAYMENT_METHOD_ID = 'UPDATE_PAYMENT_METHOD_ID';
export const UPDATE_CREDIT_CARD_DETAILS = 'UPDATE_CREDIT_CARD_DETAILS'; export const UPDATE_CREDIT_CARD_DETAILS = 'UPDATE_CREDIT_CARD_DETAILS';
export const UPDATE_IS_LOADING_PAYMENT_METHOD = 'UPDATE_IS_LOADING_PAYMENT_METHOD'; export const UPDATE_IS_LOADING_PAYMENT_METHOD = 'UPDATE_IS_LOADING_PAYMENT_METHOD';
export const UPDATE_IS_CONFIRMING_ORDER = 'UPDATE_IS_CONFIRMING_ORDER';
...@@ -68,4 +68,8 @@ export default { ...@@ -68,4 +68,8 @@ export default {
[types.UPDATE_IS_LOADING_PAYMENT_METHOD](state, isLoadingPaymentMethod) { [types.UPDATE_IS_LOADING_PAYMENT_METHOD](state, isLoadingPaymentMethod) {
state.isLoadingPaymentMethod = isLoadingPaymentMethod; state.isLoadingPaymentMethod = isLoadingPaymentMethod;
}, },
[types.UPDATE_IS_CONFIRMING_ORDER](state, isConfirmingOrder) {
state.isConfirmingOrder = isConfirmingOrder;
},
}; };
...@@ -39,6 +39,7 @@ export default ({ planData = '[]', planId, setupForCompany, fullName }) => { ...@@ -39,6 +39,7 @@ export default ({ planData = '[]', planId, setupForCompany, fullName }) => {
paymentMethodId: null, paymentMethodId: null,
creditCardDetails: {}, creditCardDetails: {},
isLoadingPaymentMethod: false, isLoadingPaymentMethod: false,
isConfirmingOrder: false,
taxRate: TAX_RATE, taxRate: TAX_RATE,
startDate: new Date(Date.now()), startDate: new Date(Date.now()),
}; };
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from 'ee/subscriptions/new/store';
import * as types from 'ee/subscriptions/new/store/mutation_types';
import { GlButton } from '@gitlab/ui';
import Component from 'ee/subscriptions/new/components/checkout/confirm_order.vue';
describe('Confirm Order', () => {
const localVue = createLocalVue();
localVue.use(Vuex);
let wrapper;
const store = createStore();
const createComponent = (opts = {}) => {
wrapper = shallowMount(Component, {
localVue,
store,
...opts,
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('Active', () => {
beforeEach(() => {
store.commit(types.UPDATE_CURRENT_STEP, 'confirmOrder');
});
it('button should be visible', () => {
expect(wrapper.find(GlButton).exists()).toBe(true);
});
});
describe('Inactive', () => {
beforeEach(() => {
store.commit(types.UPDATE_CURRENT_STEP, 'otherStep');
});
it('button should not be visible', () => {
expect(wrapper.find(GlButton).exists()).toBe(false);
});
});
});
...@@ -557,4 +557,98 @@ describe('Subscriptions Actions', () => { ...@@ -557,4 +557,98 @@ describe('Subscriptions Actions', () => {
}); });
}); });
}); });
describe('confirmOrder', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('calls confirmOrderSuccess with a redirect location on success', done => {
mock.onPost(constants.CONFIRM_ORDER_URL).replyOnce(200, { location: 'x' });
testAction(
actions.confirmOrder,
null,
{},
[{ type: 'UPDATE_IS_CONFIRMING_ORDER', payload: true }],
[{ type: 'confirmOrderSuccess', payload: 'x' }],
done,
);
});
it('calls confirmOrderError with the errors on error', done => {
mock.onPost(constants.CONFIRM_ORDER_URL).replyOnce(200, { errors: 'errors' });
testAction(
actions.confirmOrder,
null,
{},
[{ type: 'UPDATE_IS_CONFIRMING_ORDER', payload: true }],
[{ type: 'confirmOrderError', payload: '"errors"' }],
done,
);
});
it('calls confirmOrderError on failure', done => {
mock.onPost(constants.CONFIRM_ORDER_URL).replyOnce(500);
testAction(
actions.confirmOrder,
null,
{},
[{ type: 'UPDATE_IS_CONFIRMING_ORDER', payload: true }],
[{ type: 'confirmOrderError' }],
done,
);
});
});
describe('confirmOrderSuccess', () => {
it('changes the window location', done => {
const spy = jest.spyOn(window.location, 'assign').mockImplementation();
testAction(actions.confirmOrderSuccess, 'http://example.com', {}, [], [], () => {
expect(spy).toHaveBeenCalledWith('http://example.com');
done();
});
});
});
describe('confirmOrderError', () => {
it('creates a flash with a default message when no error given', done => {
testAction(
actions.confirmOrderError,
null,
{},
[{ type: 'UPDATE_IS_CONFIRMING_ORDER', payload: false }],
[],
() => {
expect(createFlash).toHaveBeenCalledWith(
'Failed to confirm your order! Please try again.',
);
done();
},
);
});
it('creates a flash with a the error message when an error is given', done => {
testAction(
actions.confirmOrderError,
'"Error"',
{},
[{ type: 'UPDATE_IS_CONFIRMING_ORDER', payload: false }],
[],
() => {
expect(createFlash).toHaveBeenCalledWith(
'Failed to confirm your order: "Error". Please try again.',
);
done();
},
);
});
});
}); });
...@@ -13,6 +13,15 @@ const state = { ...@@ -13,6 +13,15 @@ const state = {
}, },
], ],
selectedPlan: 'firstPlan', selectedPlan: 'firstPlan',
country: 'Country',
streetAddressLine1: 'Street address line 1',
streetAddressLine2: 'Street address line 2',
city: 'City',
countryState: 'State',
zipCode: 'Zip code',
organizationName: 'Organization name',
paymentMethodId: 'Payment method ID',
numberOfUsers: 1,
}; };
describe('Subscriptions Getters', () => { describe('Subscriptions Getters', () => {
...@@ -113,4 +122,26 @@ describe('Subscriptions Getters', () => { ...@@ -113,4 +122,26 @@ describe('Subscriptions Getters', () => {
expect(getters.usersPresent({ numberOfUsers: 0 })).toBe(false); expect(getters.usersPresent({ numberOfUsers: 0 })).toBe(false);
}); });
}); });
describe('confirmOrderParams', () => {
it('returns the params to confirm the order', () => {
expect(getters.confirmOrderParams(state)).toEqual({
setup_for_company: true,
customer: {
country: 'Country',
address_1: 'Street address line 1',
address_2: 'Street address line 2',
city: 'City',
state: 'State',
zip_code: 'Zip code',
company: 'Organization name',
},
subscription: {
plan_id: 'firstPlan',
payment_method_id: 'Payment method ID',
quantity: 1,
},
});
});
});
}); });
...@@ -10,6 +10,7 @@ const state = () => ({ ...@@ -10,6 +10,7 @@ const state = () => ({
countryOptions: [], countryOptions: [],
stateOptions: [], stateOptions: [],
isLoadingPaymentMethod: false, isLoadingPaymentMethod: false,
isConfirmingOrder: false,
}); });
let stateCopy; let stateCopy;
...@@ -38,6 +39,7 @@ describe('ee/subscriptions/new/store/mutation', () => { ...@@ -38,6 +39,7 @@ describe('ee/subscriptions/new/store/mutation', () => {
${types.UPDATE_PAYMENT_METHOD_ID} | ${'paymentMethodId'} | ${'paymentMethodId'} ${types.UPDATE_PAYMENT_METHOD_ID} | ${'paymentMethodId'} | ${'paymentMethodId'}
${types.UPDATE_CREDIT_CARD_DETAILS} | ${{ type: 'x' }} | ${'creditCardDetails'} ${types.UPDATE_CREDIT_CARD_DETAILS} | ${{ type: 'x' }} | ${'creditCardDetails'}
${types.UPDATE_IS_LOADING_PAYMENT_METHOD} | ${true} | ${'isLoadingPaymentMethod'} ${types.UPDATE_IS_LOADING_PAYMENT_METHOD} | ${true} | ${'isLoadingPaymentMethod'}
${types.UPDATE_IS_CONFIRMING_ORDER} | ${true} | ${'isConfirmingOrder'}
`('$mutation', ({ mutation, value, stateProp }) => { `('$mutation', ({ mutation, value, stateProp }) => {
it(`should set the ${stateProp} to the given value`, () => { it(`should set the ${stateProp} to the given value`, () => {
expect(stateCopy[stateProp]).not.toEqual(value); expect(stateCopy[stateProp]).not.toEqual(value);
......
...@@ -153,4 +153,8 @@ describe('projectsSelector default state', () => { ...@@ -153,4 +153,8 @@ describe('projectsSelector default state', () => {
it('sets isLoadingPaymentMethod to false', () => { it('sets isLoadingPaymentMethod to false', () => {
expect(state.isLoadingPaymentMethod).toEqual(false); expect(state.isLoadingPaymentMethod).toEqual(false);
}); });
it('sets isConfirmingOrder to false', () => {
expect(state.isConfirmingOrder).toBe(false);
});
}); });
...@@ -3371,6 +3371,12 @@ msgstr "" ...@@ -3371,6 +3371,12 @@ msgstr ""
msgid "Checkout|City" msgid "Checkout|City"
msgstr "" msgstr ""
msgid "Checkout|Confirm purchase"
msgstr ""
msgid "Checkout|Confirming..."
msgstr ""
msgid "Checkout|Continue to billing" msgid "Checkout|Continue to billing"
msgstr "" msgstr ""
...@@ -3392,6 +3398,12 @@ msgstr "" ...@@ -3392,6 +3398,12 @@ msgstr ""
msgid "Checkout|Exp %{expirationMonth}/%{expirationYear}" msgid "Checkout|Exp %{expirationMonth}/%{expirationYear}"
msgstr "" msgstr ""
msgid "Checkout|Failed to confirm your order! Please try again."
msgstr ""
msgid "Checkout|Failed to confirm your order: %{message}. Please try again."
msgstr ""
msgid "Checkout|Failed to load countries. Please try again." msgid "Checkout|Failed to load countries. Please try again."
msgstr "" 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