Commit e22f8d48 authored by Ryan Cobb's avatar Ryan Cobb Committed by Doug Stull

Add ability to refresh billing seat counts

parent dc6a267a
...@@ -8,8 +8,11 @@ import { ...@@ -8,8 +8,11 @@ import {
TABLE_TYPE_TRIAL, TABLE_TYPE_TRIAL,
DAYS_FOR_RENEWAL, DAYS_FOR_RENEWAL,
} from 'ee/billings/constants'; } from 'ee/billings/constants';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { getDayDifference } from '~/lib/utils/datetime/date_calculation_utility'; import { getDayDifference } from '~/lib/utils/datetime/date_calculation_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionTableRow from './subscription_table_row.vue'; import SubscriptionTableRow from './subscription_table_row.vue';
const createButtonProps = (text, href, testId) => ({ text, href, testId }); const createButtonProps = (text, href, testId) => ({ text, href, testId });
...@@ -21,6 +24,7 @@ export default { ...@@ -21,6 +24,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
SubscriptionTableRow, SubscriptionTableRow,
}, },
mixins: [glFeatureFlagsMixin()],
inject: { inject: {
planUpgradeHref: { planUpgradeHref: {
default: '', default: '',
...@@ -46,6 +50,9 @@ export default { ...@@ -46,6 +50,9 @@ export default {
freePersonalNamespace: { freePersonalNamespace: {
default: false, default: false,
}, },
refreshSeatsHref: {
default: '',
},
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -66,6 +73,9 @@ export default { ...@@ -66,6 +73,9 @@ export default {
return `${this.namespaceName}: ${planName} ${suffix}`; return `${this.namespaceName}: ${planName} ${suffix}`;
}, },
canRefreshSeats() {
return this.glFeatures.refreshBillingsSeats;
},
canRenew() { canRenew() {
const subscriptionEndDate = new Date(this.billing.subscriptionEndDate); const subscriptionEndDate = new Date(this.billing.subscriptionEndDate);
const todayDate = new Date(); const todayDate = new Date();
...@@ -141,6 +151,19 @@ export default { ...@@ -141,6 +151,19 @@ export default {
isLast(index) { isLast(index) {
return index === this.visibleRows.length - 1; return index === this.visibleRows.length - 1;
}, },
async refreshSeats() {
try {
await axios.post(this.refreshSeatsHref);
this.fetchSubscription();
} catch (error) {
createFlash({
message: s__('SubscriptionTable|Something went wrong trying to refresh seats'),
captureError: true,
error,
});
}
},
}, },
}; };
</script> </script>
...@@ -168,6 +191,15 @@ export default { ...@@ -168,6 +191,15 @@ export default {
variant="info" variant="info"
>{{ button.text }}</gl-button >{{ button.text }}</gl-button
> >
<gl-button
v-if="canRefreshSeats"
:class="{ 'gl-ml-2': buttons.length !== 0 }"
data-testid="refresh-seats-button"
category="secondary"
variant="info"
@click="refreshSeats"
>{{ s__('SubscriptionTable|Refresh Seats') }}</gl-button
>
</div> </div>
</div> </div>
<div <div
......
...@@ -23,6 +23,7 @@ export default (containerId = 'js-billing-plans') => { ...@@ -23,6 +23,7 @@ export default (containerId = 'js-billing-plans') => {
billableSeatsHref, billableSeatsHref,
planName, planName,
freePersonalNamespace, freePersonalNamespace,
refreshSeatsHref,
} = containerEl.dataset; } = containerEl.dataset;
return new Vue({ return new Vue({
...@@ -38,6 +39,7 @@ export default (containerId = 'js-billing-plans') => { ...@@ -38,6 +39,7 @@ export default (containerId = 'js-billing-plans') => {
billableSeatsHref, billableSeatsHref,
planName, planName,
freePersonalNamespace: parseBoolean(freePersonalNamespace), freePersonalNamespace: parseBoolean(freePersonalNamespace),
refreshSeatsHref,
}, },
render(createElement) { render(createElement) {
return createElement(SubscriptionApp); return createElement(SubscriptionApp);
......
...@@ -4,6 +4,10 @@ class Groups::BillingsController < Groups::ApplicationController ...@@ -4,6 +4,10 @@ class Groups::BillingsController < Groups::ApplicationController
before_action :authorize_admin_group! before_action :authorize_admin_group!
before_action :verify_namespace_plan_check_enabled before_action :verify_namespace_plan_check_enabled
before_action only: [:index] do
push_frontend_feature_flag(:refresh_billings_seats, type: :ops, default_enabled: :yaml)
end
layout 'group_settings' layout 'group_settings'
feature_category :purchase feature_category :purchase
...@@ -24,4 +28,27 @@ class Groups::BillingsController < Groups::ApplicationController ...@@ -24,4 +28,27 @@ class Groups::BillingsController < Groups::ApplicationController
render 'shared/billings/customers_dot_unavailable' render 'shared/billings/customers_dot_unavailable'
end end
end end
def refresh_seats
if Feature.enabled?(:refresh_billings_seats, type: :ops, default_enabled: :yaml)
success = update_subscription_seats
end
if success
render json: { success: true }
else
render json: { success: false }, status: :bad_request
end
end
private
def update_subscription_seats
gitlab_subscription = group.gitlab_subscription
return false unless gitlab_subscription
gitlab_subscription.refresh_seat_attributes!
gitlab_subscription.save
end
end end
...@@ -47,7 +47,11 @@ module BillingPlansHelper ...@@ -47,7 +47,11 @@ module BillingPlansHelper
billable_seats_href: billable_seats_href(namespace), billable_seats_href: billable_seats_href(namespace),
plan_name: plan&.name, plan_name: plan&.name,
free_personal_namespace: namespace.free_personal?.to_s free_personal_namespace: namespace.free_personal?.to_s
} }.tap do |attrs|
if Feature.enabled?(:refresh_billings_seats, type: :ops, default_enabled: :yaml)
attrs[:refresh_seats_href] = refresh_seats_group_billings_url(namespace)
end
end
end end
def use_new_purchase_flow?(namespace) def use_new_purchase_flow?(namespace)
......
---
name: refresh_billings_seats
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65606
rollout_issue_url:
milestone: '14.2'
type: ops
group: group::purchase
default_enabled: false
...@@ -93,7 +93,11 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -93,7 +93,11 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end end
end end
resources :billings, only: [:index] resources :billings, only: [:index] do
collection do
post :refresh_seats
end
end
get :seat_usage, to: 'seat_usage#show' get :seat_usage, to: 'seat_usage#show'
......
...@@ -6,21 +6,21 @@ RSpec.describe Groups::BillingsController do ...@@ -6,21 +6,21 @@ RSpec.describe Groups::BillingsController do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) } let_it_be(:group) { create(:group, :private) }
describe 'GET index' do before do
before do sign_in(user)
sign_in(user) stub_application_setting(check_namespace_plan: true)
stub_application_setting(check_namespace_plan: true) allow(Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?) { true }
allow(Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?) { true } end
end
def add_group_owner
group.add_owner(user)
end
describe 'GET index' do
def get_index def get_index
get :index, params: { group_id: group } get :index, params: { group_id: group }
end end
def add_group_owner
group.add_owner(user)
end
subject { response } subject { response }
context 'authorized' do context 'authorized' do
...@@ -106,4 +106,77 @@ RSpec.describe Groups::BillingsController do ...@@ -106,4 +106,77 @@ RSpec.describe Groups::BillingsController do
end end
end end
end end
describe 'POST refresh_seats' do
let_it_be(:gitlab_subscription) do
create(:gitlab_subscription, namespace: group)
end
before do
add_group_owner
end
subject(:post_refresh_seats) do
post :refresh_seats, params: { group_id: group }
end
context 'authorized' do
context 'with feature flag on' do
it 'refreshes subscription seats' do
expect { post_refresh_seats }.to change { group.gitlab_subscription.reload.seats_in_use }.from(0).to(1)
end
it 'renders 200' do
post_refresh_seats
is_expected.to have_gitlab_http_status(:ok)
end
context 'when update fails' do
before do
allow_next_found_instance_of(GitlabSubscription) do |subscription|
allow(subscription).to receive(:save).and_return(false)
end
end
it 'renders 400' do
post_refresh_seats
is_expected.to have_gitlab_http_status(:bad_request)
end
end
end
context 'with feature flag off' do
before do
stub_feature_flags(refresh_billings_seats: false)
end
it 'renders 400' do
post_refresh_seats
is_expected.to have_gitlab_http_status(:bad_request)
end
end
end
context 'unauthorized' do
it 'renders 404 when user is not an owner' do
group.add_developer(user)
post_refresh_seats
is_expected.to have_gitlab_http_status(:not_found)
end
it 'renders 404 when it is not gitlab.com' do
add_group_owner
expect(Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?).at_least(:once) { false }
post_refresh_seats
is_expected.to have_gitlab_http_status(:not_found)
end
end
end
end end
...@@ -430,6 +430,31 @@ RSpec.describe 'Billing plan pages', :feature, :js do ...@@ -430,6 +430,31 @@ RSpec.describe 'Billing plan pages', :feature, :js do
it_behaves_like 'plan with subscription table' it_behaves_like 'plan with subscription table'
end end
end end
context 'seat refresh button' do
let!(:subscription) { create(:gitlab_subscription, namespace: namespace, hosted_plan: plan, seats: 15) }
let(:page_path) { group_billings_path(namespace) }
let(:plan) { ultimate_plan }
it 'updates seat counts on click' do
visit page_path
expect(seats_in_use).to eq '0'
click_button 'Refresh Seats'
wait_for_requests
expect(seats_in_use).to eq '1'
end
end
def seats_in_use
all('[data-testid="content-cell"]').each do |cell|
label = cell.first('[data-testid="property-label"]')
break cell.find('[data-testid="property-value"]').text if label&.text == 'Seats currently in use'
end
end
end end
context 'with unexpected JSON' do context 'with unexpected JSON' do
......
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex'; import Vuex from 'vuex';
import SubscriptionTable from 'ee/billings/subscriptions/components/subscription_table.vue'; import SubscriptionTable from 'ee/billings/subscriptions/components/subscription_table.vue';
import SubscriptionTableRow from 'ee/billings/subscriptions/components/subscription_table_row.vue'; import SubscriptionTableRow from 'ee/billings/subscriptions/components/subscription_table_row.vue';
...@@ -7,6 +8,11 @@ import initialStore from 'ee/billings/subscriptions/store'; ...@@ -7,6 +8,11 @@ import initialStore from 'ee/billings/subscriptions/store';
import * as types from 'ee/billings/subscriptions/store/mutation_types'; import * as types from 'ee/billings/subscriptions/store/mutation_types';
import { mockDataSubscription } from 'ee_jest/billings/mock_data'; import { mockDataSubscription } from 'ee_jest/billings/mock_data';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
jest.mock('~/flash');
const defaultInjectedProps = { const defaultInjectedProps = {
namespaceName: 'GitLab.com', namespaceName: 'GitLab.com',
...@@ -26,13 +32,14 @@ describe('SubscriptionTable component', () => { ...@@ -26,13 +32,14 @@ describe('SubscriptionTable component', () => {
const findManageButton = () => wrapper.findByTestId('manage-button'); const findManageButton = () => wrapper.findByTestId('manage-button');
const findRenewButton = () => wrapper.findByTestId('renew-button'); const findRenewButton = () => wrapper.findByTestId('renew-button');
const findUpgradeButton = () => wrapper.findByTestId('upgrade-button'); const findUpgradeButton = () => wrapper.findByTestId('upgrade-button');
const findRefreshSeatsButton = () => wrapper.findByTestId('refresh-seats-button');
const createComponentWithStore = ({ props = {}, provide = {}, state = {} } = {}) => { const createComponentWithStore = ({ props = {}, provide = {}, state = {} } = {}) => {
store = new Vuex.Store(initialStore()); store = new Vuex.Store(initialStore());
jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(SubscriptionTable, { mount(SubscriptionTable, {
store, store,
localVue, localVue,
provide: { provide: {
...@@ -258,4 +265,80 @@ describe('SubscriptionTable component', () => { ...@@ -258,4 +265,80 @@ describe('SubscriptionTable component', () => {
}, },
); );
}); });
describe('Refresh Seats feature flag is on', () => {
let mock;
const refreshSeatsHref = '/url';
beforeEach(() => {
mock = new MockAdapter(axios);
createComponentWithStore({
state: {
isLoadingSubscription: false,
},
provide: {
refreshSeatsHref,
glFeatures: { refreshBillingsSeats: true },
},
});
});
afterEach(() => {
mock.restore();
});
it('displays the Refresh Seats button', () => {
expect(findRefreshSeatsButton().exists()).toBe(true);
});
describe('when clicked', () => {
beforeEach(async () => {
mock.onPost(refreshSeatsHref).reply(200);
findRefreshSeatsButton().trigger('click');
await waitForPromises();
});
it('makes call to BE to refresh seats', () => {
expect(mock.history.post).toHaveLength(1);
expect(createFlash).not.toHaveBeenCalled();
});
});
describe('when clicked and BE error', () => {
beforeEach(async () => {
mock.onPost(refreshSeatsHref).reply(500);
findRefreshSeatsButton().trigger('click');
await waitForPromises();
});
it('flashes error', () => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong trying to refresh seats',
captureError: true,
error: expect.any(Error),
});
});
});
});
describe('Refresh Seats feature flag is off', () => {
beforeEach(() => {
createComponentWithStore({
state: {
isLoadingSubscription: false,
},
provide: {
glFeatures: { refreshBillingsSeats: false },
},
});
});
it('does not display the Refresh Seats button', () => {
expect(findRefreshSeatsButton().exists()).toBe(false);
});
});
}); });
...@@ -6,30 +6,46 @@ RSpec.describe BillingPlansHelper, skip: Gitlab.jh? do ...@@ -6,30 +6,46 @@ RSpec.describe BillingPlansHelper, skip: Gitlab.jh? do
include Devise::Test::ControllerHelpers include Devise::Test::ControllerHelpers
describe '#subscription_plan_data_attributes' do describe '#subscription_plan_data_attributes' do
let(:group) { build(:group) }
let(:customer_portal_url) { "#{EE::SUBSCRIPTIONS_URL}/subscriptions" } let(:customer_portal_url) { "#{EE::SUBSCRIPTIONS_URL}/subscriptions" }
let(:add_seats_href) { "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats" }
let(:plan_renew_href) { "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew" }
let(:billable_seats_href) { helper.group_seat_usage_path(group) }
let(:refresh_seats_href) { helper.refresh_seats_group_billings_url(group) }
let(:group) { build(:group) }
let(:plan) do let(:plan) do
OpenStruct.new(id: 'external-paid-plan-hash-code', name: 'Bronze Plan') OpenStruct.new(id: 'external-paid-plan-hash-code', name: 'Bronze Plan')
end end
context 'when group and plan with ID present' do context 'when group and plan with ID present' do
it 'returns data attributes' do let(:base_attrs) do
add_seats_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats" {
upgrade_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}" namespace_id: group.id,
renew_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew" namespace_name: group.name,
billable_seats_href = helper.group_seat_usage_path(group) add_seats_href: add_seats_href,
plan_upgrade_href: "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}",
plan_renew_href: plan_renew_href,
customer_portal_url: customer_portal_url,
billable_seats_href: billable_seats_href,
plan_name: plan.name,
free_personal_namespace: 'false'
}
end
it 'returns data attributes' do
expect(helper.subscription_plan_data_attributes(group, plan)) expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(namespace_id: group.id, .to eq(base_attrs.merge(refresh_seats_href: refresh_seats_href))
namespace_name: group.name, end
add_seats_href: add_seats_href,
plan_upgrade_href: upgrade_href, context 'with refresh_billings_seats feature flag off' do
plan_renew_href: renew_href, before do
customer_portal_url: customer_portal_url, stub_feature_flags(refresh_billings_seats: false)
billable_seats_href: billable_seats_href, end
plan_name: plan.name,
free_personal_namespace: 'false') it 'returns data attributes' do
expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(base_attrs)
end
end end
end end
...@@ -44,42 +60,68 @@ RSpec.describe BillingPlansHelper, skip: Gitlab.jh? do ...@@ -44,42 +60,68 @@ RSpec.describe BillingPlansHelper, skip: Gitlab.jh? do
context 'when plan not present' do context 'when plan not present' do
let(:plan) { nil } let(:plan) { nil }
it 'returns attributes' do let(:base_attrs) do
add_seats_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats" {
billable_seats_href = helper.group_seat_usage_path(group) add_seats_href: add_seats_href,
renew_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew" billable_seats_href: billable_seats_href,
customer_portal_url: customer_portal_url,
namespace_id: nil,
namespace_name: group.name,
plan_renew_href: plan_renew_href,
plan_upgrade_href: nil,
plan_name: nil,
free_personal_namespace: 'false'
}
end
it 'returns attributes' do
expect(helper.subscription_plan_data_attributes(group, plan)) expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(add_seats_href: add_seats_href, .to eq(base_attrs.merge(refresh_seats_href: refresh_seats_href))
billable_seats_href: billable_seats_href, end
customer_portal_url: customer_portal_url,
namespace_id: nil, context 'with refresh_billings_seats feature flag off' do
namespace_name: group.name, before do
plan_renew_href: renew_href, stub_feature_flags(refresh_billings_seats: false)
plan_upgrade_href: nil, end
plan_name: nil,
free_personal_namespace: 'false') it 'returns data attributes' do
expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(base_attrs)
end
end end
end end
context 'when plan with ID not present' do context 'when plan with ID not present' do
let(:plan) { OpenStruct.new(id: nil, name: 'Bronze Plan') } let(:plan) { OpenStruct.new(id: nil, name: 'Bronze Plan') }
it 'returns data attributes without upgrade href' do let(:base_attrs) do
add_seats_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats" {
renew_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew" namespace_id: group.id,
billable_seats_href = helper.group_seat_usage_path(group) namespace_name: group.name,
customer_portal_url: customer_portal_url,
billable_seats_href: billable_seats_href,
add_seats_href: add_seats_href,
plan_renew_href: plan_renew_href,
plan_upgrade_href: nil,
plan_name: plan.name,
free_personal_namespace: 'false'
}
end
it 'returns data attributes without upgrade href' do
expect(helper.subscription_plan_data_attributes(group, plan)) expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(namespace_id: group.id, .to eq(base_attrs.merge(refresh_seats_href: refresh_seats_href))
namespace_name: group.name, end
customer_portal_url: customer_portal_url,
billable_seats_href: billable_seats_href, context 'with refresh_billings_seats feature flag off' do
add_seats_href: add_seats_href, before do
plan_renew_href: renew_href, stub_feature_flags(refresh_billings_seats: false)
plan_upgrade_href: nil, end
plan_name: plan.name,
free_personal_namespace: 'false') it 'returns data attributes' do
expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(base_attrs)
end
end end
end end
......
...@@ -31443,6 +31443,9 @@ msgstr "" ...@@ -31443,6 +31443,9 @@ msgstr ""
msgid "SubscriptionTable|Next invoice" msgid "SubscriptionTable|Next invoice"
msgstr "" msgstr ""
msgid "SubscriptionTable|Refresh Seats"
msgstr ""
msgid "SubscriptionTable|Renew" msgid "SubscriptionTable|Renew"
msgstr "" msgstr ""
...@@ -31458,6 +31461,9 @@ msgstr "" ...@@ -31458,6 +31461,9 @@ msgstr ""
msgid "SubscriptionTable|See usage" msgid "SubscriptionTable|See usage"
msgstr "" msgstr ""
msgid "SubscriptionTable|Something went wrong trying to refresh seats"
msgstr ""
msgid "SubscriptionTable|Subscription end date" msgid "SubscriptionTable|Subscription end date"
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