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 {
TABLE_TYPE_TRIAL,
DAYS_FOR_RENEWAL,
} 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 { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionTableRow from './subscription_table_row.vue';
const createButtonProps = (text, href, testId) => ({ text, href, testId });
......@@ -21,6 +24,7 @@ export default {
GlLoadingIcon,
SubscriptionTableRow,
},
mixins: [glFeatureFlagsMixin()],
inject: {
planUpgradeHref: {
default: '',
......@@ -46,6 +50,9 @@ export default {
freePersonalNamespace: {
default: false,
},
refreshSeatsHref: {
default: '',
},
},
computed: {
...mapState([
......@@ -66,6 +73,9 @@ export default {
return `${this.namespaceName}: ${planName} ${suffix}`;
},
canRefreshSeats() {
return this.glFeatures.refreshBillingsSeats;
},
canRenew() {
const subscriptionEndDate = new Date(this.billing.subscriptionEndDate);
const todayDate = new Date();
......@@ -141,6 +151,19 @@ export default {
isLast(index) {
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>
......@@ -168,6 +191,15 @@ export default {
variant="info"
>{{ 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
......
......@@ -23,6 +23,7 @@ export default (containerId = 'js-billing-plans') => {
billableSeatsHref,
planName,
freePersonalNamespace,
refreshSeatsHref,
} = containerEl.dataset;
return new Vue({
......@@ -38,6 +39,7 @@ export default (containerId = 'js-billing-plans') => {
billableSeatsHref,
planName,
freePersonalNamespace: parseBoolean(freePersonalNamespace),
refreshSeatsHref,
},
render(createElement) {
return createElement(SubscriptionApp);
......
......@@ -4,6 +4,10 @@ class Groups::BillingsController < Groups::ApplicationController
before_action :authorize_admin_group!
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'
feature_category :purchase
......@@ -24,4 +28,27 @@ class Groups::BillingsController < Groups::ApplicationController
render 'shared/billings/customers_dot_unavailable'
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
......@@ -47,7 +47,11 @@ module BillingPlansHelper
billable_seats_href: billable_seats_href(namespace),
plan_name: plan&.name,
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
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
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'
......
......@@ -6,21 +6,21 @@ RSpec.describe Groups::BillingsController do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
describe 'GET index' do
before do
sign_in(user)
stub_application_setting(check_namespace_plan: true)
allow(Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?) { true }
end
before do
sign_in(user)
stub_application_setting(check_namespace_plan: true)
allow(Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?) { true }
end
def add_group_owner
group.add_owner(user)
end
describe 'GET index' do
def get_index
get :index, params: { group_id: group }
end
def add_group_owner
group.add_owner(user)
end
subject { response }
context 'authorized' do
......@@ -106,4 +106,77 @@ RSpec.describe Groups::BillingsController do
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
......@@ -430,6 +430,31 @@ RSpec.describe 'Billing plan pages', :feature, :js do
it_behaves_like 'plan with subscription table'
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
context 'with unexpected JSON' do
......
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 SubscriptionTable from 'ee/billings/subscriptions/components/subscription_table.vue';
import SubscriptionTableRow from 'ee/billings/subscriptions/components/subscription_table_row.vue';
......@@ -7,6 +8,11 @@ import initialStore from 'ee/billings/subscriptions/store';
import * as types from 'ee/billings/subscriptions/store/mutation_types';
import { mockDataSubscription } from 'ee_jest/billings/mock_data';
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 = {
namespaceName: 'GitLab.com',
......@@ -26,13 +32,14 @@ describe('SubscriptionTable component', () => {
const findManageButton = () => wrapper.findByTestId('manage-button');
const findRenewButton = () => wrapper.findByTestId('renew-button');
const findUpgradeButton = () => wrapper.findByTestId('upgrade-button');
const findRefreshSeatsButton = () => wrapper.findByTestId('refresh-seats-button');
const createComponentWithStore = ({ props = {}, provide = {}, state = {} } = {}) => {
store = new Vuex.Store(initialStore());
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = extendedWrapper(
shallowMount(SubscriptionTable, {
mount(SubscriptionTable, {
store,
localVue,
provide: {
......@@ -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
include Devise::Test::ControllerHelpers
describe '#subscription_plan_data_attributes' do
let(:group) { build(:group) }
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
OpenStruct.new(id: 'external-paid-plan-hash-code', name: 'Bronze Plan')
end
context 'when group and plan with ID present' do
it 'returns data attributes' 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}"
renew_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew"
billable_seats_href = helper.group_seat_usage_path(group)
let(:base_attrs) do
{
namespace_id: group.id,
namespace_name: group.name,
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))
.to eq(namespace_id: group.id,
namespace_name: group.name,
add_seats_href: add_seats_href,
plan_upgrade_href: upgrade_href,
plan_renew_href: renew_href,
customer_portal_url: customer_portal_url,
billable_seats_href: billable_seats_href,
plan_name: plan.name,
free_personal_namespace: 'false')
.to eq(base_attrs.merge(refresh_seats_href: refresh_seats_href))
end
context 'with refresh_billings_seats feature flag off' do
before do
stub_feature_flags(refresh_billings_seats: false)
end
it 'returns data attributes' do
expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(base_attrs)
end
end
end
......@@ -44,42 +60,68 @@ RSpec.describe BillingPlansHelper, skip: Gitlab.jh? do
context 'when plan not present' do
let(:plan) { nil }
it 'returns attributes' do
add_seats_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats"
billable_seats_href = helper.group_seat_usage_path(group)
renew_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew"
let(:base_attrs) do
{
add_seats_href: add_seats_href,
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))
.to eq(add_seats_href: add_seats_href,
billable_seats_href: billable_seats_href,
customer_portal_url: customer_portal_url,
namespace_id: nil,
namespace_name: group.name,
plan_renew_href: renew_href,
plan_upgrade_href: nil,
plan_name: nil,
free_personal_namespace: 'false')
.to eq(base_attrs.merge(refresh_seats_href: refresh_seats_href))
end
context 'with refresh_billings_seats feature flag off' do
before do
stub_feature_flags(refresh_billings_seats: false)
end
it 'returns data attributes' do
expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(base_attrs)
end
end
end
context 'when plan with ID not present' do
let(:plan) { OpenStruct.new(id: nil, name: 'Bronze Plan') }
it 'returns data attributes without upgrade href' do
add_seats_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats"
renew_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/renew"
billable_seats_href = helper.group_seat_usage_path(group)
let(:base_attrs) do
{
namespace_id: group.id,
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))
.to eq(namespace_id: group.id,
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: renew_href,
plan_upgrade_href: nil,
plan_name: plan.name,
free_personal_namespace: 'false')
.to eq(base_attrs.merge(refresh_seats_href: refresh_seats_href))
end
context 'with refresh_billings_seats feature flag off' do
before do
stub_feature_flags(refresh_billings_seats: false)
end
it 'returns data attributes' do
expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(base_attrs)
end
end
end
......
......@@ -31443,6 +31443,9 @@ msgstr ""
msgid "SubscriptionTable|Next invoice"
msgstr ""
msgid "SubscriptionTable|Refresh Seats"
msgstr ""
msgid "SubscriptionTable|Renew"
msgstr ""
......@@ -31458,6 +31461,9 @@ msgstr ""
msgid "SubscriptionTable|See usage"
msgstr ""
msgid "SubscriptionTable|Something went wrong trying to refresh seats"
msgstr ""
msgid "SubscriptionTable|Subscription end date"
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