Commit ee9f65da authored by Douwe Maan's avatar Douwe Maan

Merge branch '7772-add-subscription-table-to-gitlab-com-billing-areas' into 'master'

Resolve "Add subscription table to GitLab.com billing areas"

Closes #7772

See merge request gitlab-org/gitlab-ee!7885
parents 71d8b252 bf8bc1a7
......@@ -28,6 +28,7 @@ const Api = {
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
geoNodesPath: '/api/:version/geo_nodes',
subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -322,6 +323,12 @@ const Api = {
});
},
userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
return axios.get(url);
},
buildUrl(url) {
let urlRoot = '';
if (gon.relative_url_root != null) {
......
@import 'framework/variables';
@import 'framework/variables_overrides';
@import 'framework/mixins';
......
......@@ -393,6 +393,10 @@ Settings.cron_jobs['prune_web_hook_logs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['prune_web_hook_logs_worker']['cron'] ||= '0 */1 * * *'
Settings.cron_jobs['prune_web_hook_logs_worker']['job_class'] = 'PruneWebHookLogsWorker'
Settings.cron_jobs['update_max_seats_used_for_gitlab_com_subscriptions_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['update_max_seats_used_for_gitlab_com_subscriptions_worker']['cron'] ||= '0 12 * * *'
Settings.cron_jobs['update_max_seats_used_for_gitlab_com_subscriptions_worker']['job_class'] = 'UpdateMaxSeatsUsedForGitlabComSubscriptionsWorker'
#
# Sidekiq
#
......
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20181204135932) do
ActiveRecord::Schema.define(version: 20181206121340) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -1307,6 +1307,21 @@ ActiveRecord::Schema.define(version: 20181204135932) do
t.index ["upload_id"], name: "index_geo_upload_deleted_events_on_upload_id", using: :btree
end
create_table "gitlab_subscriptions", id: :bigserial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.date "start_date"
t.date "end_date"
t.date "trial_ends_on"
t.integer "namespace_id"
t.integer "hosted_plan_id"
t.integer "max_seats_used", default: 0
t.integer "seats", default: 0
t.boolean "trial", default: false
t.index ["hosted_plan_id"], name: "index_gitlab_subscriptions_on_hosted_plan_id", using: :btree
t.index ["namespace_id"], name: "index_gitlab_subscriptions_on_namespace_id", unique: true, using: :btree
end
create_table "gpg_key_subkeys", force: :cascade do |t|
t.integer "gpg_key_id", null: false
t.binary "keyid"
......@@ -3215,6 +3230,8 @@ ActiveRecord::Schema.define(version: 20181204135932) do
add_foreign_key "geo_repository_renamed_events", "projects", on_delete: :cascade
add_foreign_key "geo_repository_updated_events", "projects", on_delete: :cascade
add_foreign_key "geo_reset_checksum_events", "projects", on_delete: :cascade
add_foreign_key "gitlab_subscriptions", "namespaces"
add_foreign_key "gitlab_subscriptions", "plans", column: "hosted_plan_id", name: "fk_bd0c4019c3", on_delete: :cascade
add_foreign_key "gpg_key_subkeys", "gpg_keys", on_delete: :cascade
add_foreign_key "gpg_keys", "users", on_delete: :cascade
add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify
......
<script>
import { mapActions } from 'vuex';
import SubscriptionTable from './subscription_table.vue';
export default {
name: 'SubscriptionApp',
components: {
SubscriptionTable,
},
props: {
namespaceId: {
type: String,
required: false,
default: null,
},
},
created() {
this.setNamespaceId(this.namespaceId);
},
methods: {
...mapActions('subscription', ['setNamespaceId']),
},
};
</script>
<template>
<subscription-table />
</template>
<script>
import _ from 'underscore';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import SubscriptionTableRow from './subscription_table_row.vue';
import { CUSTOMER_PORTAL_URL } from '../constants';
import { s__, sprintf } from '~/locale';
export default {
name: 'SubscriptionTable',
components: {
SubscriptionTableRow,
GlLoadingIcon,
},
computed: {
...mapState('subscription', ['isLoading', 'hasError', 'plan', 'rows', 'endpoint']),
...mapGetters('subscription', ['isFreePlan']),
subscriptionHeader() {
let suffix = this.isFreePlan ? '' : s__('SubscriptionTable|subscription');
if (!this.isFreePlan && this.plan.trial) {
suffix += ` - ${s__('SubscriptionTable|Trial')}`;
}
return sprintf(s__('SubscriptionTable|GitLab.com %{planName} %{suffix}'), {
planName: this.isFreePlan ? s__('SubscriptionTable|Free') : _.escape(this.plan.name),
suffix,
});
},
actionButtonText() {
return this.isFreePlan ? s__('SubscriptionTable|Upgrade') : s__('SubscriptionTable|Manage');
},
},
mounted() {
this.fetchSubscription();
},
methods: {
...mapActions('subscription', ['fetchSubscription']),
},
customerPortalUrl: CUSTOMER_PORTAL_URL,
};
</script>
<template>
<div>
<div
v-if="!isLoading && !hasError"
class="card prepend-top-default subscription-table js-subscription-table"
>
<div class="js-subscription-header card-header">
<strong> {{ subscriptionHeader }} </strong>
<div class="controls">
<a
:href="$options.customerPortalUrl"
target="_blank"
rel="noopener noreferrer"
class="btn btn-inverted-secondary"
>
{{ actionButtonText }}
</a>
</div>
</div>
<div class="card-body flex-grid d-flex flex-column flex-sm-row flex-md-row flex-lg-column">
<subscription-table-row
v-for="(row, i) in rows"
:key="`subscription-rows-${i}`"
:header="row.header"
:columns="row.columns"
:is-free-plan="isFreePlan"
/>
</div>
</div>
<gl-loading-icon
v-else-if="isLoading && !hasError"
:label="s__('SubscriptionTable|Loading subscriptions')"
:size="3"
class="prepend-top-10 append-bottom-10"
/>
</div>
</template>
<script>
import { dateInWords } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
export default {
name: 'SubscriptionTableRow',
components: {
Icon,
Popover,
},
props: {
header: {
type: Object,
required: true,
},
columns: {
type: Array,
required: true,
},
isFreePlan: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
getPopoverOptions(col) {
const defaults = {
placement: 'bottom',
};
return { ...defaults, ...col.popover };
},
getDisplayValue(col) {
if (col.isDate && col.value) {
return dateInWords(new Date(col.value));
}
// let's display '-' instead of 0 for the 'Free' plan
if (this.isFreePlan && col.value === 0) {
return ' - ';
}
return typeof col.value !== 'undefined' && col.value !== null ? col.value : ' - ';
},
},
};
</script>
<template>
<div class="grid-row d-flex flex-grow-1 flex-column flex-sm-column flex-md-column flex-lg-row">
<div class="grid-cell header-cell">
<span class="icon-wrapper">
<icon v-if="header.icon" class="append-right-8" :name="header.icon" aria-hidden="true" />
{{ header.title }}
</span>
</div>
<template v-for="(col, i) in columns">
<div :key="`subscription-col-${i}`" class="grid-cell" :class="[col.hidden ? 'no-value' : '']">
<span class="property-label"> {{ col.label }} </span>
<popover v-if="col.popover" :options="getPopoverOptions(col)" />
<p
class="property-value prepend-top-5 append-bottom-0"
:class="[col.colClass ? col.colClass : '']"
>
{{ getDisplayValue(col) }}
</p>
</div>
</template>
</div>
</template>
export const USAGE_ROW_INDEX = 0;
export const BILLING_ROW_INDEX = 1;
export const CUSTOMER_PORTAL_URL = 'https://customers.gitlab.com/subscriptions';
import Vue from 'vue';
import SubscriptionApp from './components/app.vue';
import store from './stores';
export default (containerId = 'js-billing-plans') => {
const containerEl = document.getElementById(containerId);
if (!containerEl) {
return false;
}
return new Vue({
el: containerEl,
store,
components: {
SubscriptionApp,
},
data() {
const { dataset } = this.$options.el;
const { namespaceId } = dataset;
return {
namespaceId,
};
},
render(createElement) {
return createElement('subscription-app', {
props: {
namespaceId: this.namespaceId,
},
});
},
});
};
import Vue from 'vue';
import Vuex from 'vuex';
import subscription from './modules/subscription/index';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
modules: {
subscription,
},
});
import * as types from './mutation_types';
import createFlash from '~/flash';
import { __ } from '~/locale';
import API from '~/api';
/**
* SUBSCRIPTION TABLE
*/
export const setNamespaceId = ({ commit }, namespaceId) => {
commit(types.SET_NAMESPACE_ID, namespaceId);
};
export const fetchSubscription = ({ dispatch, state }) => {
dispatch('requestSubscription');
return API.userSubscription(state.namespaceId)
.then(({ data }) => dispatch('receiveSubscriptionSuccess', data))
.catch(() => dispatch('receiveSubscriptionError'));
};
export const requestSubscription = ({ commit }) => commit(types.REQUEST_SUBSCRIPTION);
export const receiveSubscriptionSuccess = ({ commit }, response) =>
commit(types.RECEIVE_SUBSCRIPTION_SUCCESS, response);
export const receiveSubscriptionError = ({ commit }) => {
createFlash(__('An error occurred while loading the subscription details.'));
commit(types.RECEIVE_SUBSCRIPTION_ERROR);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const isFreePlan = state => state.plan.code === null;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default {
namespaced: true,
actions,
mutations,
getters,
state,
};
{
"plan": {
"name": "Gold",
"code": "gold",
"trial": false
},
"usage": {
"seats_in_subscription": 100,
"seats_in_use": 98,
"max_seats_used": 104,
"seats_owed": 4
},
"billing": {
"subscription_start_date": "2018-07-11",
"subscription_end_date": "2019-07-11",
"last_invoice": "2018-09-01",
"next_invoice": "2018-10-01"
}
}
export const SET_NAMESPACE_ID = 'SET_NAMESPACE_ID';
export const REQUEST_SUBSCRIPTION = 'REQUEST_SUBSCRIPTION';
export const RECEIVE_SUBSCRIPTION_SUCCESS = 'RECEIVE_SUBSCRIPTION_SUCCESS';
export const RECEIVE_SUBSCRIPTION_ERROR = 'RECEIVE_SUBSCRIPTION_ERROR';
import Vue from 'vue';
import * as types from './mutation_types';
import { USAGE_ROW_INDEX, BILLING_ROW_INDEX } from '../../../constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
[types.SET_NAMESPACE_ID](state, payload) {
state.namespaceId = payload;
},
[types.REQUEST_SUBSCRIPTION](state) {
state.isLoading = true;
state.hasError = false;
},
[types.RECEIVE_SUBSCRIPTION_SUCCESS](state, payload) {
const data = convertObjectPropsToCamelCase(payload, { deep: true });
const { plan, usage, billing } = data;
state.plan = plan;
/*
* Update column values for billing and usage row.
* We iterate over the rows within the state
* and update only the column's value property in the state
* with the data we received from the API for the given column
*/
[USAGE_ROW_INDEX, BILLING_ROW_INDEX].forEach(rowIdx => {
const currentRow = state.rows[rowIdx];
currentRow.columns.forEach(currentCol => {
if (rowIdx === USAGE_ROW_INDEX) {
Vue.set(currentCol, 'value', usage[currentCol.id]);
} else if (rowIdx === BILLING_ROW_INDEX) {
Vue.set(currentCol, 'value', billing[currentCol.id]);
}
});
});
state.isLoading = false;
},
[types.RECEIVE_SUBSCRIPTION_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
};
import { s__ } from '~/locale';
export default () => ({
isLoading: false,
hasError: false,
namespaceId: null,
plan: {
code: null,
name: null,
trial: false,
},
rows: [
{
header: {
icon: 'monitor',
title: s__('SubscriptionTable|Usage'),
},
columns: [
{
id: 'seatsInSubscription',
label: s__('SubscriptionTable|Seats in subscription'),
value: null,
colClass: 'number',
},
{
id: 'seatsInUse',
label: s__('SubscriptionTable|Seats currently in use'),
value: null,
colClass: 'number',
popover: {
content: s__(`SubscriptionTable|Usage count is performed once a day at 12:00 PM.`),
},
},
{
id: 'maxSeatsUsed',
label: s__('SubscriptionTable|Max seats used'),
value: null,
colClass: 'number',
popover: {
content: s__(
'SubscriptionTable|This is the maximum number of users that have existed at the same time since this subscription started.',
),
},
},
{
id: 'seatsOwed',
label: s__('SubscriptionTable|Seats owed'),
value: null,
colClass: 'number',
popover: {
content: s__(
'SubscriptionTable|GitLab allows you to continue using your subscription even if you exceed the number of seats you purchased. You will be required to pay for these seats upon renewal.',
),
},
},
],
},
{
header: {
icon: 'calendar',
title: s__('SubscriptionTable|Billing'),
},
columns: [
{
id: 'subscriptionStartDate',
label: s__('SubscriptionTable|Subscription start date'),
value: null,
isDate: true,
},
{
id: 'subscriptionEndDate',
label: s__('SubscriptionTable|Subscription end date'),
value: null,
isDate: true,
},
{
id: 'lastInvoice',
label: s__('SubscriptionTable|Last invoice'),
value: null,
isDate: true,
popover: {
content: s__(
'SubscriptionTable|This is the last time the GitLab.com team was in contact with you to settle any outstanding balances.',
),
},
hidden: true,
},
{
id: 'nextInvoice',
label: s__('SubscriptionTable|Next invoice'),
value: null,
isDate: true,
popover: {
content: s__(
'SubscriptionTable|This is the next date when the GitLab.com team is scheduled to get in contact with you to settle any outstanding balances.',
),
},
hidden: true,
},
],
},
],
});
import initSubscriptions from 'ee/billings';
document.addEventListener('DOMContentLoaded', () => {
initSubscriptions();
});
......@@ -154,3 +154,56 @@
}
}
}
.subscription-table {
.flex-grid {
.grid-cell {
.property-label {
color: $gl-text-color-secondary;
}
.btn-help {
color: $blue-600;
}
.property-value {
color: $gl-text-color;
&.number {
font-size: 20px;
line-height: 24px;
}
}
.icon-wrapper {
line-height: 16px;
vertical-align: baseline;
svg {
vertical-align: middle;
}
}
&.header-cell {
font-weight: $gl-font-weight-bold;
}
&.no-value {
> * {
display: none;
}
@include media-breakpoint-down(sm) {
display: none;
}
}
}
@include media-breakpoint-up(lg) {
.header-cell {
width: 144px;
flex: none;
}
}
}
}
......@@ -175,6 +175,18 @@ module EE
project
end
# For now, we are not billing for members with a Guest role for subscriptions
# with a Gold plan. The other plans will treat Guest members as a regular member
# for billing purposes.
override :billable_members_count
def billable_members_count(requested_hosted_plan = nil)
if [actual_plan_name, requested_hosted_plan].include?(Namespace::GOLD_PLAN)
users_with_descendants.excluding_guests.count
else
users_with_descendants.count
end
end
private
def custom_project_templates_group_allowed
......
......@@ -31,6 +31,7 @@ module EE
belongs_to :plan
has_one :namespace_statistics
has_one :gitlab_subscription
scope :with_plan, -> { where.not(plan_id: nil) }
......@@ -43,6 +44,8 @@ module EE
validate :validate_plan_name
validate :validate_shared_runner_minutes_support
delegate :trial?, :trial_ends_on, to: :gitlab_subscription, allow_nil: true
before_create :sync_membership_lock_with_parent
# Changing the plan or other details may invalidate this cache
......@@ -104,11 +107,10 @@ module EE
available_features[feature]
end
# The main difference between the "plan" column and this method is that "plan"
# returns nil / "" when it has no plan. Having no plan means it's a "free" plan.
#
def actual_plan
self.plan || Plan.find_by(name: FREE_PLAN)
subscription = find_or_create_subscription
subscription&.hosted_plan
end
def actual_plan_name
......@@ -185,12 +187,20 @@ module EE
def plans
@plans ||=
if parent_id
Plan.where(id: self_and_ancestors.with_plan.reorder(nil).select(:plan_id))
Plan.hosted_plans_for_namespaces(self_and_ancestors.select(:id))
else
Array(plan)
Plan.hosted_plans_for_namespaces(self)
end
end
# When a purchasing a GL.com plan for a User namespace
# we only charge for a single user.
# This method is overwritten in Group where we made the calculation
# for Group namespaces.
def billable_members_count(_requested_hosted_plan = nil)
1
end
def eligible_for_trial?
::Gitlab.com? &&
parent_id.nil? &&
......@@ -199,7 +209,7 @@ module EE
end
def trial_active?
trial_ends_on.present? && trial_ends_on >= Date.today
trial? && trial_ends_on.present? && trial_ends_on >= Date.today
end
def trial_expired?
......@@ -261,5 +271,21 @@ module EE
self.file_template_project_id = nil
end
def find_or_create_subscription
# Hosted subscriptions are only available for root groups for now.
return if parent_id
gitlab_subscription || generate_subscription
end
def generate_subscription
create_gitlab_subscription(
plan_code: plan&.name,
trial: trial_active?,
start_date: created_at,
seats: 0
)
end
end
end
class GitlabSubscription < ActiveRecord::Base
belongs_to :namespace
belongs_to :hosted_plan, class_name: 'Plan'
validates :seats, :start_date, presence: true
validates :namespace_id, uniqueness: true, allow_blank: true
delegate :name, :title, to: :hosted_plan, prefix: :plan, allow_nil: true
scope :with_a_paid_hosted_plan, -> do
joins(:hosted_plan).where(trial: false, 'plans.name' => Plan::PAID_HOSTED_PLANS)
end
def seats_in_use
namespace.billable_members_count
end
# The purpose of max_seats_used is similar to what we do for EE licenses
# with the historical max. We want to know how many extra users the customer
# has added to their group (users above the number purchased on their subscription).
# Then, on the next month we're going to automatically charge the customers for those extra users.
def seats_owed
return 0 unless has_a_paid_hosted_plan?
[0, max_seats_used - seats].max
end
def has_a_paid_hosted_plan?
!trial? &&
hosted? &&
seats > 0 &&
Plan::PAID_HOSTED_PLANS.include?(plan_name)
end
def plan_code=(code)
code ||= Namespace::FREE_PLAN
self.hosted_plan = Plan.find_by(name: code)
end
private
def hosted?
namespace_id.present?
end
end
# frozen_string_literal: true
class Plan < ActiveRecord::Base
PAID_HOSTED_PLANS = %w[bronze silver gold].freeze
ALL_HOSTED_PLANS = (PAID_HOSTED_PLANS + ['early_adopter']).freeze
has_many :namespaces
has_many :hosted_subscriptions, class_name: 'GitlabSubscription', foreign_key: 'hosted_plan_id'
def self.hosted_plans_for_namespaces(namespaces)
namespaces = Array(namespaces)
Plan
.joins(:hosted_subscriptions)
.where(name: ALL_HOSTED_PLANS)
.where(gitlab_subscriptions: { namespace_id: namespaces })
.distinct
end
end
- namespace = local_assigns.fetch(:namespace)
- return unless Gitlab::CurrentSettings.should_check_namespace_plan? && namespace.plan.present?
- return unless Gitlab::CurrentSettings.should_check_namespace_plan? && namespace.actual_plan.present?
%span.plan-badge.has-tooltip{ data: { plan: namespace.plan.name }, title: "#{namespace.plan.title} Plan" }
%span.plan-badge.has-tooltip{ data: { plan: namespace.actual_plan.name }, title: "#{namespace.actual_plan.title} Plan" }
= custom_icon('icon_premium')
......@@ -3,10 +3,10 @@
%li
%span.light Plan:
- if namespace.plan.present?
%strong.plan-badge.inline{ data: { plan: namespace.plan.name } }
- if namespace.actual_plan.present?
%strong.plan-badge.inline{ data: { plan: namespace.actual_plan.name } }
= custom_icon('icon_premium')
= namespace.plan.title
= namespace.actual_plan.title
- else
%strong.plan-badge.inline
= custom_icon('icon_premium')
......
- current_plan = subscription_plan_info(@plans_data, @group.actual_plan_name)
- page_title "Billing"
- if @top_most_group
- top_most_group_plan = subscription_plan_info(@plans_data, @top_most_group.actual_plan_name)
= render 'shared/billings/billing_plan_header', namespace: @group, plan: top_most_group_plan, parent_group: @top_most_group
- else
= render 'shared/billings/billing_plans', plans_data: @plans_data, namespace: @group
= render 'shared/billings/billing_plan_header', namespace: @group, plan: current_plan
#js-billing-plans{ data: { namespace_id: @group.id } }
......@@ -17,6 +17,7 @@
- cronjob:ldap_sync
- cronjob:update_all_mirrors
- cronjob:pseudonymizer
- cronjob:update_max_seats_used_for_gitlab_com_subscriptions
- gcp_cluster:cluster_update_app
- gcp_cluster:cluster_wait_for_app_update
......
class UpdateMaxSeatsUsedForGitlabComSubscriptionsWorker
include ApplicationWorker
include CronjobQueue
# rubocop: disable CodeReuse/ActiveRecord
def perform
return unless ::Gitlab::Database.postgresql?
return unless ::Gitlab::CurrentSettings.should_check_namespace_plan?
GitlabSubscription.with_a_paid_hosted_plan.find_in_batches(batch_size: 100) do |subscriptions|
tuples = []
subscriptions.each do |subscription|
seats_in_use = subscription.seats_in_use
next if subscription.max_seats_used >= seats_in_use
tuples << [subscription.id, seats_in_use]
end
if tuples.present?
GitlabSubscription.connection.execute <<-EOF
UPDATE gitlab_subscriptions AS s SET max_seats_used = v.max_seats_used
FROM (VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}) AS v(id, max_seats_used)
WHERE s.id = v.id
EOF
end
end
end
end
---
title: Add subscription table to GitLab.com billing areas
merge_request: 7885
author:
type: other
class CreateGitlabSubscriptions < ActiveRecord::Migration[5.0]
DOWNTIME = false
def up
create_table :gitlab_subscriptions, id: :bigserial do |t|
t.timestamps_with_timezone null: false
t.date :start_date
t.date :end_date
t.date :trial_ends_on
t.references :namespace, index: { unique: true }, foreign_key: true
t.integer :hosted_plan_id, index: true
t.integer :max_seats_used, default: 0
t.integer :seats, default: 0
t.boolean :trial, default: false
end
end
def down
drop_table :gitlab_subscriptions
end
end
# frozen_string_literal: true
class AddHostedPlanIdFkToGitlabSubscriptions < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key :gitlab_subscriptions, :plans, column: :hosted_plan_id
end
def down
remove_foreign_key :gitlab_subscriptions, column: :hosted_plan_id
end
end
# frozen_string_literal: true
class GenerateGitlabComSubscriptionsFromPlanId < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
MIGRATION = 'GenerateGitlabSubscriptions'.freeze
class Namespace < ActiveRecord::Base
self.table_name = 'namespaces'
scope :with_plan, -> { where.not(plan_id: nil) }
scope :without_subscription, -> do
joins("LEFT JOIN gitlab_subscriptions ON namespaces.id = gitlab_subscriptions.namespace_id")
.where(gitlab_subscriptions: { id: nil })
end
include ::EachBatch
end
disable_ddl_transaction!
def up
return unless Gitlab.dev_env_or_com?
say 'Populating GitlabSubscription from Namespace with a `plan_id`'
bulk_queue_background_migration_jobs_by_range(Namespace.with_plan.without_subscription, MIGRATION)
end
def down
GitlabSubscription.delete_all
end
end
......@@ -81,10 +81,12 @@ module EE
extend ActiveSupport::Concern
prepended do
expose :trial_ends_on
expose :shared_runners_minutes_limit, if: ->(_, options) { options[:current_user]&.admin? }
expose :billable_members_count do |namespace, options|
namespace.billable_members_count(options[:requested_hosted_plan])
end
expose :plan, if: ->(namespace, opts) { ::Ability.allowed?(opts[:current_user], :admin_namespace, namespace) } do |namespace, _|
namespace.plan&.name
namespace.actual_plan&.name
end
end
end
......@@ -469,6 +471,27 @@ module EE
object.operations_feature_flags.ordered
end
end
class GitlabSubscription < Grape::Entity
expose :plan do
expose :plan_name, as: :code
expose :plan_title, as: :name
expose :trial
end
expose :usage do
expose :seats, as: :seats_in_subscription
expose :seats_in_use
expose :max_seats_used
expose :seats_owed
end
expose :billing do
expose :start_date, as: :subscription_start_date
expose :end_date, as: :subscription_end_date
expose :trial_ends_on
end
end
end
end
end
......@@ -4,7 +4,32 @@ module EE
extend ActiveSupport::Concern
prepended do
helpers do
extend ::Gitlab::Utils::Override
params :optional_list_params_ee do
# Used only by GitLab.com
optional :requested_hosted_plan, type: String, desc: "Name of the hosted plan requested by the customer"
end
override :custom_namespace_present_options
def custom_namespace_present_options
{ requested_hosted_plan: params[:requested_hosted_plan] }
end
end
resource :namespaces do
helpers do
params :gitlab_subscription_optional_attributes do
optional :seats, type: Integer, default: 0, desc: 'The number of seats purchased'
optional :max_seats_used, type: Integer, default: 0, desc: 'The max number of active users detected in the last month'
optional :plan_code, type: String, desc: 'The code of the purchased plan'
optional :end_date, type: Date, desc: 'The date when subscription expires'
optional :trial, type: Grape::API::Boolean, desc: 'Wether the subscription is trial'
optional :trial_ends_on, type: Date, desc: 'The date when the trial expires'
end
end
desc 'Update a namespace' do
success Entities::Namespace
end
......@@ -17,10 +42,8 @@ module EE
authenticated_as_admin!
namespace = find_namespace(params[:id])
trial_ends_on = params[:trial_ends_on]
break not_found!('Namespace') unless namespace
break bad_request!("Invalid trial expiration date") if trial_ends_on&.past?
if namespace.update(declared_params)
present namespace, with: ::API::Entities::Namespace, current_user: current_user
......@@ -28,6 +51,63 @@ module EE
render_validation_error!(namespace)
end
end
desc 'Create a subscription for the namespace' do
success ::EE::API::Entities::GitlabSubscription
end
params do
requires :start_date, type: Date, desc: 'The date when subscription was started'
use :gitlab_subscription_optional_attributes
end
post ":id/gitlab_subscription" do
authenticated_as_admin!
namespace = find_namespace!(params[:id])
subscription_params = declared_params(include_missing: false)
subscription = namespace.create_gitlab_subscription(subscription_params)
if subscription.persisted?
present subscription, with: ::EE::API::Entities::GitlabSubscription
else
render_validation_error!(subscription)
end
end
desc 'Returns the subscription for the namespace' do
success ::EE::API::Entities::GitlabSubscription
end
get ":id/gitlab_subscription" do
namespace = find_namespace!(params[:id])
authorize! :admin_namespace, namespace
present namespace.gitlab_subscription || {}, with: ::EE::API::Entities::GitlabSubscription
end
desc 'Update the subscription for the namespace' do
success ::EE::API::Entities::GitlabSubscription
end
params do
optional :start_date, type: Date, desc: 'The date when subscription was started'
use :gitlab_subscription_optional_attributes
end
put ":id/gitlab_subscription" do
authenticated_as_admin!
namespace = find_namespace!(params[:id])
subscription = namespace.gitlab_subscription
trial_ends_on = params[:trial_ends_on]
not_found!('GitlabSubscription') unless subscription
bad_request!("Invalid trial expiration date") if trial_ends_on&.past?
if subscription.update(declared_params(include_missing: false))
present subscription, with: ::EE::API::Entities::GitlabSubscription
else
render_validation_error!(subscription)
end
end
end
end
end
......
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
class GenerateGitlabSubscriptions
class Namespace < ActiveRecord::Base
self.table_name = 'namespaces'
self.inheritance_column = :_type_disabled # Disable STI
scope :with_plan, -> { where.not(plan_id: nil) }
scope :without_subscription, -> do
joins("LEFT JOIN gitlab_subscriptions ON namespaces.id = gitlab_subscriptions.namespace_id")
.where(gitlab_subscriptions: { id: nil })
end
def trial_active?
trial_ends_on.present? && trial_ends_on >= Date.today
end
end
class GitlabSubscription < ActiveRecord::Base
self.table_name = 'gitlab_subscriptions'
end
def perform(start_id, stop_id)
now = Time.now
# Some fields like seats or end_date will be properly updated by a script executed
# from the subscription portal after this MR hits production.
rows = Namespace
.with_plan
.without_subscription
.where(id: start_id..stop_id)
.select(:id, :plan_id, :trial_ends_on, :created_at)
.map do |namespace|
{
namespace_id: namespace.id,
hosted_plan_id: namespace.plan_id,
trial: namespace.trial_active?,
start_date: namespace.created_at.to_date,
seats: 0,
created_at: now,
updated_at: now
}
end
Gitlab::Database.bulk_insert(:gitlab_subscriptions, rows)
end
end
end
end
......@@ -73,7 +73,8 @@ describe Projects::IssuesController do
context 'licensed by namespace' do
let(:globally_licensed) { true }
let(:namespace) { create(:group, :private, plan: :bronze_plan) }
let(:namespace) { create(:group, :private) }
let!(:gitlab_subscriptions) { create(:gitlab_subscription, :bronze, namespace: namespace) }
let(:project) { create(:project, namespace: namespace) }
before do
......
......@@ -68,7 +68,8 @@ describe Projects::Settings::IntegrationsController do
end
context 'and namespace has a plan' do
let(:namespace) { create(:group, :private, plan: :bronze_plan) }
let(:namespace) { create(:group, :private) }
let!(:gitlab_subscription) { create(:gitlab_subscription, :bronze, namespace: namespace) }
it_behaves_like 'endpoint without disabled services'
end
......
FactoryBot.define do
factory :gitlab_subscription do
namespace
association :hosted_plan, factory: :gold_plan
seats 10
start_date { Date.today }
end_date { Date.today.advance(years: 1) }
trial false
trait :free do
hosted_plan_id nil
end
trait :early_adopter do
association :hosted_plan, factory: :early_adopter_plan
end
trait :bronze do
association :hosted_plan, factory: :bronze_plan
end
trait :silver do
association :hosted_plan, factory: :silver_plan
end
trait :gold do
association :hosted_plan, factory: :gold_plan
end
end
end
......@@ -128,8 +128,6 @@ describe 'Billing plan pages', :feature do
visit group_billings_path(group)
end
include_examples 'displays all plans and correct actions'
it 'displays plan header' do
page.within('.billing-plan-header') do
expect(page).to have_content("#{group.name} is currently on the Bronze plan")
......@@ -137,6 +135,14 @@ describe 'Billing plan pages', :feature do
expect(page).to have_css('.billing-plan-logo svg')
end
end
it 'does not display the billing plans table' do
expect(page).not_to have_css('.billing-plans')
end
it 'displays subscription table', :js do
expect(page).to have_selector('.js-subscription-table')
end
end
end
......@@ -152,7 +158,7 @@ describe 'Billing plan pages', :feature do
visit group_billings_path(subgroup2)
end
it 'displays plan header only' do
it 'displays plan header' do
page.within('.billing-plan-header') do
expect(page).to have_content("#{subgroup2.full_name} is currently on the Bronze plan")
expect(page).to have_css('.billing-plan-logo svg')
......
require 'spec_helper'
describe 'Groups > Billing', :js do
let!(:user) { create(:user) }
let!(:group) { create(:group) }
let!(:bronze_plan) { create(:bronze_plan) }
before do
stub_request(:get, %r{https://customers.gitlab.com/gitlab_plans})
.to_return(status: 200, body: File.new(Rails.root.join('ee/spec/fixtures/gitlab_com_plans.json')))
stub_application_setting(check_namespace_plan: true)
group.add_owner(user)
sign_in(user)
end
context 'with a free plan' do
before do
create(:gitlab_subscription, namespace: group, hosted_plan: nil, seats: 15)
end
it 'shows the proper title for the plan' do
visit group_billings_path(group)
expect(page).to have_content("#{group.name} is currently on the Free plan")
end
end
context 'with a paid plan' do
before do
create(:gitlab_subscription, namespace: group, hosted_plan: bronze_plan, seats: 15)
end
it 'shows the proper title for the plan' do
visit group_billings_path(group)
expect(page).to have_content("#{group.name} is currently on the Bronze plan")
end
end
context 'with a legacy paid plan' do
before do
group.update_attribute(:plan, bronze_plan)
end
it 'shows the proper title for the plan' do
visit group_billings_path(group)
expect(page).to have_content("#{group.name} is currently on the Bronze plan")
end
end
end
......@@ -50,7 +50,7 @@ describe 'Projects > Push Rules', :js do
context 'when disabled' do
it 'does not render the setting checkbox' do
project.namespace.update!(plan_id: bronze_plan.id)
create(:gitlab_subscription, :bronze, namespace: project.namespace)
visit project_settings_repository_path(project)
......@@ -60,7 +60,7 @@ describe 'Projects > Push Rules', :js do
context 'when enabled' do
it 'renders the setting checkbox' do
project.namespace.update!(plan_id: gold_plan.id)
create(:gitlab_subscription, :gold, namespace: project.namespace)
visit project_settings_repository_path(project)
......
......@@ -133,7 +133,7 @@ describe EpicsFinder do
.to receive(:should_check_namespace_plan?)
.and_return(true)
group.update(plan: create(:gold_plan))
create(:gitlab_subscription, :gold, namespace: group)
amount = ActiveRecord::QueryRecorder.new { epics.to_a }.count
......
[
{
"about_page_href": "https://about.gitlab.com/gitlab-com/",
"code": "free",
"features": [
{
"highlight": false,
"title": "Unlimited repositories"
},
{
"highlight": false,
"title": "2,000 CI pipeline minutes/month"
},
{
"highlight": false,
"title": "Basic support"
},
{
"highlight": false,
"title": "Built-in CI/CD"
},
{
"highlight": false,
"title": "Cycle Analytics"
},
{
"highlight": false,
"title": "Issue Boards"
},
{
"highlight": false,
"title": "Time tracking"
},
{
"highlight": false,
"title": "GitLab Pages"
}
],
"free": true,
"name": "Free",
"price_per_month": 0.0,
"price_per_year": 0.0,
"purchase_link": {
"action": "current_plan",
"href": null
}
},
{
"about_page_href": "https://about.gitlab.com/gitlab-com/",
"code": "bronze",
"features": [
{
"highlight": false,
"title": "Unlimited repositories"
},
{
"highlight": false,
"title": "2,000 CI pipeline minutes/month"
},
{
"highlight": false,
"title": "Next day support"
},
{
"highlight": true,
"title": "All Free features"
},
{
"highlight": false,
"title": "Multiple issue boards"
},
{
"highlight": false,
"title": "Burndown charts"
},
{
"highlight": false,
"title": "Push rules"
},
{
"highlight": false,
"title": "Related Issues"
}
],
"free": false,
"name": "Bronze",
"price_per_month": 4.0,
"price_per_year": 48.0,
"purchase_link": {
"action": "upgrade",
"href": "http://customers.gitlab.com/subscriptions/new?plan_id=2c92a0ff5a840412015aa3cde86f2ba6"
}
},
{
"about_page_href": "https://about.gitlab.com/gitlab-com/",
"code": "silver",
"features": [
{
"highlight": false,
"title": "Unlimited repositories"
},
{
"highlight": false,
"title": "10,000 CI pipeline minutes/month"
},
{
"highlight": false,
"title": "4 hour support"
},
{
"highlight": true,
"title": "All Bronze features"
},
{
"highlight": false,
"title": "Service desk"
}
],
"free": false,
"name": "Silver",
"price_per_month": 19.0,
"price_per_year": 228.0,
"purchase_link": {
"action": "upgrade",
"href": "http://customers.gitlab.com/subscriptions/new?plan_id=2c92a0fd5a840403015aa6d9ea2c46d6"
}
},
{
"about_page_href": "https://about.gitlab.com/gitlab-com/",
"code": "gold",
"features": [
{
"highlight": false,
"title": "Unlimited repositories"
},
{
"highlight": false,
"title": "50,000 CI pipeline minutes/month"
},
{
"highlight": false,
"title": "4 hour support"
},
{
"highlight": true,
"title": "All Silver features"
},
{
"highlight": false,
"title": "Epics (preview)"
},
{
"highlight": false,
"title": "New features coming soon"
}
],
"free": false,
"name": "Gold",
"price_per_month": 99.0,
"price_per_year": 1188.0,
"purchase_link": {
"action": "upgrade",
"href": "http://customers.gitlab.com/subscriptions/new?plan_id=2c92a0fc5a83f01d015aa6db83c45aac"
}
}
]
import Vue from 'vue';
import component from 'ee/billings/components/subscription_table_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Subscription Table Row', () => {
let vm;
let props;
const Component = Vue.extend(component);
const header = {
icon: 'monitor',
title: 'Test title',
};
const columns = [
{
id: 'a',
label: 'Column A',
value: 100,
colClass: 'number',
},
{
id: 'b',
label: 'Column B',
value: 200,
popover: {
content: 'This is a tooltip',
},
},
];
afterEach(() => {
vm.$destroy();
});
describe('when loaded', () => {
beforeEach(() => {
props = { header, columns };
vm = mountComponent(Component, props);
});
it(`should render one header cell and ${columns.length} columns in total`, () => {
expect(vm.$el.querySelectorAll('.grid-cell')).toHaveLength(columns.length + 1);
});
it('should render a title in the header cell', () => {
expect(vm.$el.querySelector('.header-cell').textContent).toContain(props.header.title);
});
it('should render an icon in the header cell', () => {
expect(vm.$el.querySelector(`.header-cell .ic-${header.icon}`)).not.toBe(null);
});
columns.forEach((col, idx) => {
it(`should render label and value in column ${col.label}`, () => {
const currentCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[idx];
expect(currentCol.querySelector('.property-label').textContent).toContain(col.label);
expect(currentCol.querySelector('.property-value').textContent).toContain(col.value);
});
});
it('should append the "number" css class to property value in "Column A"', () => {
const currentCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[0];
expect(currentCol.querySelector('.property-value').classList.contains('number')).toBe(true);
});
it('should render an info icon in "Column B"', () => {
const currentCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[1];
expect(currentCol.querySelector('.btn-help')).not.toBe(null);
});
});
});
import Vue from 'vue';
import component from 'ee/billings/components/subscription_table.vue';
import createStore from 'ee/billings/stores';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import mockDataSubscription from 'ee/billings/stores/modules/subscription/mock_data_subscription.json';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
describe('Subscription Table', () => {
const Component = Vue.extend(component);
let store;
let vm;
beforeEach(() => {
store = createStore();
vm = createComponentWithStore(Component, store, {});
spyOn(vm.$store, 'dispatch');
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(store);
});
it('renders loading icon', done => {
vm.$store.state.subscription.isLoading = true;
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
})
.then(done)
.catch(done.fail);
});
describe('with success', () => {
const namespaceId = 1;
beforeEach(done => {
vm.$store.state.subscription.namespaceId = namespaceId;
vm.$store.commit(`subscription/${types.RECEIVE_SUBSCRIPTION_SUCCESS}`, mockDataSubscription);
vm.$store.state.subscription.isLoading = false;
vm.$nextTick(done);
});
it('should render the card title "GitLab.com Gold subscription"', () => {
expect(vm.$el.querySelector('.js-subscription-header strong').textContent.trim()).toBe(
'GitLab.com Gold subscription',
);
});
it('should render a link labelled "Manage" in the card header', () => {
expect(vm.$el.querySelector('.js-subscription-header .btn').textContent.trim()).toBe(
'Manage',
);
});
it('should render a link linking to the customer portal', () => {
expect(vm.$el.querySelector('.js-subscription-header .btn').getAttribute('href')).toBe(
'https://customers.gitlab.com/subscriptions',
);
});
it('should render a "Usage" and a "Billing" row', () => {
expect(vm.$el.querySelectorAll('.grid-row')).toHaveLength(2);
});
});
});
import subscriptionState from 'ee/billings/stores/modules/subscription/state';
// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
const newState = {
subscription: subscriptionState(),
};
store.replaceState(newState);
};
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import state from 'ee/billings/stores/modules/subscription/state';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import * as actions from 'ee/billings/stores/modules/subscription/actions';
import mockDataSubscription from './data/mock_data_subscription.json';
describe('subscription actions', () => {
let mockedState;
let mock;
beforeEach(() => {
mockedState = state();
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('setNamespaceId', () => {
it('should commit the correct mutuation', done => {
const namespaceId = 1;
testAction(
actions.setNamespaceId,
namespaceId,
mockedState,
[
{
type: types.SET_NAMESPACE_ID,
payload: namespaceId,
},
],
[],
done,
);
});
});
describe('fetchSubscription', () => {
beforeEach(() => {
gon.api_version = 'v4';
mockedState.namespaceId = 1;
});
describe('on success', () => {
beforeEach(() => {
mock
.onGet(/\/api\/v4\/namespaces\/\d+\/gitlab_subscription(.*)$/)
.replyOnce(200, mockDataSubscription);
});
it('should dispatch the request and success actions', done => {
testAction(
actions.fetchSubscription,
{},
mockedState,
[],
[
{ type: 'requestSubscription' },
{
type: 'receiveSubscriptionSuccess',
payload: mockDataSubscription,
},
],
done,
);
});
});
describe('on error', () => {
beforeEach(() => {
mock.onGet(/\/api\/v4\/namespaces\/\d+\/gitlab_subscription(.*)$/).replyOnce(404, {});
});
it('should dispatch the request and error actions', done => {
testAction(
actions.fetchSubscription,
{},
mockedState,
[],
[{ type: 'requestSubscription' }, { type: 'receiveSubscriptionError' }],
done,
);
});
});
});
describe('requestSubscription', () => {
it('should commit the request mutation', done => {
testAction(
actions.requestSubscription,
{},
state,
[{ type: types.REQUEST_SUBSCRIPTION }],
[],
done,
);
});
});
describe('receiveSubscriptionSuccess', () => {
it('should commit the success mutation', done => {
testAction(
actions.receiveSubscriptionSuccess,
mockDataSubscription,
mockedState,
[
{
type: types.RECEIVE_SUBSCRIPTION_SUCCESS,
payload: mockDataSubscription,
},
],
[],
done,
);
});
});
describe('receiveSubscriptionError', () => {
it('should commit the error mutation', done => {
testAction(
actions.receiveSubscriptionError,
{},
mockedState,
[{ type: types.RECEIVE_SUBSCRIPTION_ERROR }],
[],
done,
);
});
});
});
{
"plan": {
"name": "Gold",
"code": "gold",
"trial": false
},
"usage": {
"seats_in_subscription": 100,
"seats_in_use": 98,
"max_seats_used": 104,
"seats_owed": 4
},
"billing": {
"subscription_start_date": "2018-07-11",
"subscription_end_date": "2019-07-11",
"last_invoice": "2018-09-01",
"next_invoice": "2018-10-01"
}
}
import State from 'ee/billings/stores/modules/subscription/state';
import * as getters from 'ee/billings/stores/modules/subscription/getters';
describe('subscription module getters', () => {
let state;
beforeEach(() => {
state = State();
});
describe('isFreePlan', () => {
it('should return false', () => {
const plan = {
name: 'Gold',
code: 'gold',
};
state.plan = plan;
expect(getters.isFreePlan(state)).toBe(false);
});
it('should return true', () => {
const plan = {
name: null,
code: null,
};
state.plan = plan;
expect(getters.isFreePlan(state)).toBe(true);
});
});
});
import createState from 'ee/billings/stores/modules/subscription/state';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import mutations from 'ee/billings/stores/modules/subscription/mutations';
import { USAGE_ROW_INDEX, BILLING_ROW_INDEX } from 'ee/billings/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import mockData from './data/mock_data_subscription.json';
describe('subscription module mutations', () => {
describe('SET_PNAMESPACE_ID', () => {
it('should set "namespaceId" to "1"', () => {
const state = createState();
const namespaceId = '1';
mutations[types.SET_NAMESPACE_ID](state, namespaceId);
expect(state.namespaceId).toEqual(namespaceId);
});
});
describe('REQUEST_SUBSCRIPTION', () => {
let state;
beforeEach(() => {
state = createState();
mutations[types.REQUEST_SUBSCRIPTION](state);
});
it('should set "isLoading" to "true", ()', () => {
expect(state.isLoading).toBeTruthy();
});
});
describe('RECEIVE_SUBSCRIPTION_SUCCESS', () => {
let payload;
let state;
beforeEach(() => {
payload = mockData;
state = createState();
mutations[types.RECEIVE_SUBSCRIPTION_SUCCESS](state, payload);
});
it('should set "isLoading" to "false"', () => {
expect(state.isLoading).toBeFalsy();
});
it('should set "plan" attributes', () => {
expect(state.plan.code).toBe(payload.plan.code);
expect(state.plan.name).toBe(payload.plan.name);
expect(state.plan.trial).toBe(payload.plan.trial);
});
it('should set the column values on the "Usage" row', () => {
const usageRow = state.rows[USAGE_ROW_INDEX];
const data = convertObjectPropsToCamelCase(payload, { deep: true });
usageRow.columns.forEach(column => {
expect(column.value).toBe(data.usage[column.id]);
});
});
it('should set the column values on the "Billing" row', () => {
const billingow = state.rows[BILLING_ROW_INDEX];
const data = convertObjectPropsToCamelCase(payload, { deep: true });
billingow.columns.forEach(column => {
expect(column.value).toBe(data.billing[column.id]);
});
});
});
describe('RECEIVE_SUBSCRIPTION_ERROR', () => {
let state;
beforeEach(() => {
state = createState();
mutations[types.RECEIVE_SUBSCRIPTION_ERROR](state);
});
it('should set "isLoading" to "false"', () => {
expect(state.isLoading).toBeFalsy();
});
it('should set "hasError" to "true"', () => {
expect(state.hasError).toBeTruthy();
});
});
});
require 'spec_helper'
describe EE::Gitlab::Ci::Pipeline::Chain::Limit::Activity do
set(:namespace) { create(:namespace, plan: :gold_plan) }
set(:namespace) { create(:namespace) }
set(:project) { create(:project, namespace: namespace) }
set(:user) { create(:user) }
......@@ -17,7 +17,8 @@ describe EE::Gitlab::Ci::Pipeline::Chain::Limit::Activity do
context 'when active pipelines limit is exceeded' do
before do
project.namespace.plan.update_column(:active_pipelines_limit, 1)
gold_plan = create(:gold_plan, active_pipelines_limit: 1)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
create(:ci_pipeline, project: project, status: 'pending')
create(:ci_pipeline, project: project, status: 'running')
......
require 'spec_helper'
describe EE::Gitlab::Ci::Pipeline::Chain::Limit::Size do
set(:namespace) { create(:namespace, plan: :gold_plan) }
set(:namespace) { create(:namespace) }
set(:project) { create(:project, :repository, namespace: namespace) }
set(:user) { create(:user) }
......@@ -19,7 +19,8 @@ describe EE::Gitlab::Ci::Pipeline::Chain::Limit::Size do
context 'when pipeline size limit is exceeded' do
before do
project.namespace.plan.update_column(:pipeline_size_limit, 1)
gold_plan = create(:gold_plan, pipeline_size_limit: 1)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
step.perform!
end
......
require 'spec_helper'
describe EE::Gitlab::Ci::Pipeline::Quota::Activity do
set(:namespace) { create(:namespace, plan: :gold_plan) }
set(:namespace) { create(:namespace) }
set(:gold_plan) { create(:gold_plan) }
set(:project) { create(:project, namespace: namespace) }
let(:limit) { described_class.new(namespace, project) }
before do
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
shared_context 'pipeline activity limit exceeded' do
before do
create(:ci_pipeline, project: project, status: 'created')
create(:ci_pipeline, project: project, status: 'pending')
create(:ci_pipeline, project: project, status: 'running')
namespace.plan.update_column(:active_pipelines_limit, 1)
gold_plan.update_column(:active_pipelines_limit, 1)
end
end
shared_context 'pipeline activity limit not exceeded' do
before do
namespace.plan.update_column(:active_pipelines_limit, 2)
gold_plan.update_column(:active_pipelines_limit, 2)
end
end
describe '#enabled?' do
context 'when limit is enabled in plan' do
before do
namespace.plan.update_column(:active_pipelines_limit, 10)
gold_plan.update_column(:active_pipelines_limit, 10)
end
it 'is enabled' do
......@@ -35,7 +40,7 @@ describe EE::Gitlab::Ci::Pipeline::Quota::Activity do
context 'when limit is not enabled' do
before do
namespace.plan.update_column(:active_pipelines_limit, 0)
gold_plan.update_column(:active_pipelines_limit, 0)
end
it 'is not enabled' do
......
require 'spec_helper'
describe EE::Gitlab::Ci::Pipeline::Quota::Size do
set(:namespace) { create(:namespace, plan: :gold_plan) }
set(:namespace) { create(:namespace) }
set(:gold_plan) { create(:gold_plan) }
set(:project) { create(:project, :repository, namespace: namespace) }
let(:pipeline) { build_stubbed(:ci_pipeline, project: project) }
let(:limit) { described_class.new(namespace, pipeline) }
before do
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
shared_context 'pipeline size limit exceeded' do
let(:pipeline) do
config = { rspec: { script: 'rspec' },
......@@ -16,7 +21,7 @@ describe EE::Gitlab::Ci::Pipeline::Quota::Size do
end
before do
namespace.plan.update_column(:pipeline_size_limit, 1)
gold_plan.update_column(:pipeline_size_limit, 1)
end
end
......@@ -24,14 +29,14 @@ describe EE::Gitlab::Ci::Pipeline::Quota::Size do
let(:pipeline) { build(:ci_pipeline_with_one_job, project: project) }
before do
namespace.plan.update_column(:pipeline_size_limit, 2)
gold_plan.update_column(:pipeline_size_limit, 2)
end
end
describe '#enabled?' do
context 'when limit is enabled in plan' do
before do
namespace.plan.update_column(:pipeline_size_limit, 10)
gold_plan.update_column(:pipeline_size_limit, 10)
end
it 'is enabled' do
......@@ -41,7 +46,7 @@ describe EE::Gitlab::Ci::Pipeline::Quota::Size do
context 'when limit is not enabled' do
before do
namespace.plan.update_column(:pipeline_size_limit, 0)
gold_plan.update_column(:pipeline_size_limit, 0)
end
it 'is not enabled' do
......
require 'spec_helper'
describe GitlabSubscription do
describe 'validations' do
it { is_expected.to validate_presence_of(:seats) }
it { is_expected.to validate_presence_of(:start_date) }
it do
subject.namespace = create(:namespace)
is_expected.to validate_uniqueness_of(:namespace_id)
end
end
describe 'associations' do
it { is_expected.to belong_to(:namespace) }
it { is_expected.to belong_to(:hosted_plan) }
end
describe '#seats_in_use' do
let!(:user_1) { create(:user) }
let!(:user_2) { create(:user) }
let!(:blocked_user) { create(:user, :blocked) }
let!(:user_namespace) { create(:user).namespace }
let!(:user_project) { create(:project, namespace: user_namespace) }
let!(:group) { create(:group) }
let!(:subgroup_1) { create(:group, parent: group) }
let!(:subgroup_2) { create(:group, parent: group) }
let!(:gitlab_subscription) { create(:gitlab_subscription, namespace: group) }
before do
%i[free_plan bronze_plan silver_plan gold_plan].each do |plan|
create(plan)
end
end
it 'returns count of members' do
group.add_developer(user_1)
expect(gitlab_subscription.seats_in_use).to eq(1)
end
it 'also counts users from subgroups', :postgresql do
group.add_developer(user_1)
subgroup_1.add_developer(user_2)
expect(gitlab_subscription.seats_in_use).to eq(2)
end
it 'does not count duplicated members', :postgresql do
group.add_developer(user_1)
subgroup_1.add_developer(user_2)
subgroup_2.add_developer(user_2)
expect(gitlab_subscription.seats_in_use).to eq(2)
end
it 'does not count blocked members' do
group.add_developer(user_1)
group.add_developer(blocked_user)
expect(group.member_count).to eq(2)
expect(gitlab_subscription.seats_in_use).to eq(1)
end
context 'with guest members' do
before do
group.add_guest(user_1)
end
context 'with a gold plan' do
it 'excludes these members' do
gitlab_subscription.update!(plan_code: 'gold')
expect(gitlab_subscription.seats_in_use).to eq(0)
end
end
context 'with other plans' do
%w[bronze silver].each do |plan|
it 'excludes these members' do
gitlab_subscription.update!(plan_code: plan)
expect(gitlab_subscription.seats_in_use).to eq(1)
end
end
end
end
context 'when subscription is for a User' do
before do
gitlab_subscription.update!(namespace: user_namespace)
user_project.add_developer(user_1)
user_project.add_developer(user_2)
end
it 'always returns 1 seat' do
%w[bronze silver gold].each do |plan|
user_namespace.update!(plan: plan)
expect(gitlab_subscription.seats_in_use).to eq(1)
end
end
end
end
describe '#seats_owed' do
let!(:bronze_plan) { create(:bronze_plan) }
let!(:early_adopter_plan) { create(:early_adopter_plan) }
let!(:gitlab_subscription) { create(:gitlab_subscription, subscription_attrs) }
before do
gitlab_subscription.update!(seats: 5, max_seats_used: 10)
end
shared_examples 'always returns a total of 0' do
it 'does not update max_seats_used' do
expect(gitlab_subscription.seats_owed).to eq(0)
end
end
context 'with a free plan' do
let(:subscription_attrs) { { hosted_plan: nil } }
include_examples 'always returns a total of 0'
end
context 'with a trial plan' do
let(:subscription_attrs) { { hosted_plan: bronze_plan, trial: true } }
include_examples 'always returns a total of 0'
end
context 'with an early adopter plan' do
let(:subscription_attrs) { { hosted_plan: early_adopter_plan } }
include_examples 'always returns a total of 0'
end
context 'with a paid plan' do
let(:subscription_attrs) { { hosted_plan: bronze_plan } }
it 'calculates the number of owed seats' do
expect(gitlab_subscription.reload.seats_owed).to eq(5)
end
end
end
end
......@@ -2,13 +2,20 @@ require 'spec_helper'
describe Namespace do
let!(:namespace) { create(:namespace) }
let!(:free_plan) { create(:free_plan) }
let!(:bronze_plan) { create(:bronze_plan) }
let!(:silver_plan) { create(:silver_plan) }
let!(:gold_plan) { create(:gold_plan) }
it { is_expected.to have_one(:namespace_statistics) }
it { is_expected.to have_one(:gitlab_subscription) }
it { is_expected.to belong_to(:plan) }
it { is_expected.to delegate_method(:shared_runners_minutes).to(:namespace_statistics) }
it { is_expected.to delegate_method(:shared_runners_seconds).to(:namespace_statistics) }
it { is_expected.to delegate_method(:shared_runners_seconds_last_reset).to(:namespace_statistics) }
it { is_expected.to delegate_method(:trial?).to(:gitlab_subscription) }
it { is_expected.to delegate_method(:trial_ends_on).to(:gitlab_subscription) }
context 'scopes' do
describe '.with_plan' do
......@@ -138,14 +145,16 @@ describe Namespace do
end
describe '#feature_available?' do
let(:plan_license) { :bronze_plan }
let(:group) { create(:group, plan: plan_license) }
let(:hosted_plan) { create(:bronze_plan) }
let(:group) { create(:group) }
let(:licensed_feature) { :service_desk }
let(:feature) { licensed_feature }
subject { group.feature_available?(feature) }
before do
create(:gitlab_subscription, namespace: group, hosted_plan: hosted_plan)
stub_licensed_features(licensed_feature => true)
end
......@@ -171,7 +180,7 @@ describe Namespace do
end
context 'when feature available on the plan' do
let(:plan_license) { :gold_plan }
let(:hosted_plan) { create(:gold_plan) }
context 'when feature available for current group' do
it 'returns true' do
......@@ -192,7 +201,7 @@ describe Namespace do
context 'when feature not available in the plan' do
let(:feature) { :deploy_board }
let(:plan_license) { :bronze_plan }
let(:hosted_plan) { create(:bronze_plan) }
it 'returns false' do
is_expected.to be_falsy
......@@ -201,7 +210,6 @@ describe Namespace do
end
context 'when the feature is temporarily available on the entire instance' do
let(:license_plan) { :free_plan }
let(:feature) { :ci_cd_projects }
before do
......@@ -245,7 +253,7 @@ describe Namespace do
context 'when free plan has limit defined' do
before do
create(:free_plan, active_pipelines_limit: 40)
free_plan.update_column(:active_pipelines_limit, 40)
end
it 'returns a free plan limits' do
......@@ -255,7 +263,7 @@ describe Namespace do
context 'when associated plan has no limit defined' do
before do
namespace.plan = create(:gold_plan)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
it 'returns zero' do
......@@ -265,8 +273,8 @@ describe Namespace do
context 'when limit is defined' do
before do
namespace.plan = create(:gold_plan)
namespace.plan.update_column(:active_pipelines_limit, 10)
gold_plan.update_column(:active_pipelines_limit, 10)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
it 'returns a number of maximum active pipelines' do
......@@ -284,7 +292,7 @@ describe Namespace do
context 'when free plan has limit defined' do
before do
create(:free_plan, pipeline_size_limit: 40)
free_plan.update_column(:pipeline_size_limit, 40)
end
it 'returns a free plan limits' do
......@@ -294,7 +302,7 @@ describe Namespace do
context 'when associated plan has no limits defined' do
before do
namespace.plan = create(:gold_plan)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
it 'returns zero' do
......@@ -304,8 +312,8 @@ describe Namespace do
context 'when limit is defined' do
before do
namespace.plan = create(:gold_plan)
namespace.plan.update_column(:pipeline_size_limit, 15)
gold_plan.update_column(:pipeline_size_limit, 15)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
it 'returns a number of maximum pipeline size' do
......@@ -531,31 +539,38 @@ describe Namespace do
describe '#actual_plan' do
context 'when namespace has a plan associated' do
before do
namespace.plan = create(:gold_plan)
namespace.update_attribute(:plan, gold_plan)
end
it 'returns an associated plan' do
expect(namespace.plan).not_to be_nil
expect(namespace.actual_plan.name).to eq 'gold'
it 'generates a subscription with that plan code' do
expect(namespace.actual_plan).to eq(gold_plan)
expect(namespace.gitlab_subscription).to be_present
end
end
context 'when namespace does not have plan associated' do
context 'when namespace has a subscription associated' do
before do
create(:free_plan)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
it 'returns the plan from the subscription' do
expect(namespace.actual_plan).to eq(gold_plan)
expect(namespace.gitlab_subscription).to be_present
end
end
it 'returns a free plan object' do
expect(namespace.plan).to be_nil
expect(namespace.actual_plan.name).to eq 'free'
context 'when namespace does not have a subscription associated' do
it 'generates a subscription with the Free plan' do
expect(namespace.actual_plan).to eq(free_plan)
expect(namespace.gitlab_subscription).to be_present
end
end
end
describe '#actual_plan_name' do
context 'when namespace has a plan associated' do
context 'when namespace has a subscription associated' do
before do
namespace.plan = create(:gold_plan)
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
end
it 'returns an associated plan name' do
......@@ -563,13 +578,52 @@ describe Namespace do
end
end
context 'when namespace does not have plan associated' do
context 'when namespace does not have subscription associated' do
it 'returns a free plan name' do
expect(namespace.actual_plan_name).to eq 'free'
end
end
end
describe '#billable_members_count' do
context 'with a user namespace' do
let(:user) { create(:user) }
it 'returns 1' do
expect(user.namespace.billable_members_count).to eq(1)
end
end
context 'with a group namespace' do
let(:group) { create(:group) }
let(:developer) { create(:user) }
let(:guest) { create(:user) }
before do
group.add_developer(developer)
group.add_guest(guest)
end
context 'with a gold plan' do
it 'does not count guest users' do
create(:gitlab_subscription, namespace: group, hosted_plan: gold_plan)
expect(group.billable_members_count).to eq(1)
end
end
context 'with other plans' do
%i[bronze_plan silver_plan].each do |plan|
it 'counts guest users' do
create(:gitlab_subscription, namespace: group, hosted_plan: send(plan))
expect(group.billable_members_count).to eq(2)
end
end
end
end
end
describe '#file_template_project_id' do
it 'is cleared before validation' do
project = create(:project, namespace: namespace)
......
......@@ -161,7 +161,8 @@ ICON_STATUS_HTML
end
context 'when namespace has a plan' do
let(:namespace) { create(:group, :private, plan: :bronze_plan) }
let(:namespace) { create(:group, :private) }
let!(:gitlab_subscription) { create(:gitlab_subscription, :bronze, namespace: namespace) }
it_behaves_like 'an enabled jenkins deprecated service'
end
......
......@@ -191,7 +191,8 @@ describe JenkinsService do
end
context 'when namespace has a plan' do
let(:namespace) { create(:group, :private, plan: :bronze_plan) }
let(:namespace) { create(:group, :private) }
let!(:gitlab_subscription) { create(:gitlab_subscription, :bronze, namespace: namespace) }
it 'adds default web hook headers to the request' do
jenkins_service.execute(push_sample_data)
......
......@@ -309,9 +309,11 @@ describe Project do
end
describe '#feature_available?' do
let(:namespace) { build_stubbed(:namespace) }
let(:project) { build_stubbed(:project, namespace: namespace) }
let(:user) { build_stubbed(:user) }
let(:namespace) { create(:namespace) }
let(:plan_license) { nil }
let!(:gitlab_subscription) { create(:gitlab_subscription, namespace: namespace, hosted_plan: plan_license) }
let(:project) { create(:project, namespace: namespace) }
let(:user) { create(:user) }
subject { project.feature_available?(feature, user) }
......@@ -677,7 +679,7 @@ describe Project do
end
context 'Service Desk available in namespace plan' do
let(:namespace) { create(:namespace, plan: :silver_plan) }
let!(:gitlab_subscription) { create(:gitlab_subscription, :silver, namespace: namespace) }
it 'is enabled' do
expect(project.service_desk_enabled?).to be_truthy
......@@ -979,6 +981,7 @@ describe Project do
context 'and namespace has a plan' do
let(:namespace) { create(:group, :private, plan: :silver_plan) }
let!(:gitlab_subscription) { create(:gitlab_subscription, :silver, namespace: namespace) }
it_behaves_like 'project without disabled services'
end
......@@ -1121,9 +1124,10 @@ describe Project do
end
describe '#licensed_features' do
let(:plan_license) { :free_plan }
let(:plan_license) { :free }
let(:global_license) { create(:license) }
let(:group) { create(:group, plan: plan_license) }
let(:group) { create(:group) }
let!(:gitlab_subscription) { create(:gitlab_subscription, plan_license, namespace: group) }
let(:project) { create(:project, group: group) }
before do
......@@ -1144,7 +1148,7 @@ describe Project do
end
context 'when bronze' do
let(:plan_license) { :bronze_plan }
let(:plan_license) { :bronze }
it 'filters for bronze features' do
is_expected.to contain_exactly(:audit_events, :geo)
......@@ -1152,7 +1156,7 @@ describe Project do
end
context 'when silver' do
let(:plan_license) { :silver_plan }
let(:plan_license) { :silver }
it 'filters for silver features' do
is_expected.to contain_exactly(:service_desk, :audit_events, :geo)
......@@ -1160,7 +1164,7 @@ describe Project do
end
context 'when gold' do
let(:plan_license) { :gold_plan }
let(:plan_license) { :gold }
it 'filters for gold features' do
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo)
......@@ -1168,14 +1172,15 @@ describe Project do
end
context 'when free plan' do
let(:plan_license) { :free_plan }
let(:plan_license) { :free }
it 'filters out paid features' do
is_expected.to contain_exactly(:geo)
end
context 'when public project and namespace' do
let(:group) { create(:group, :public, plan: plan_license) }
let(:group) { create(:group, :public) }
let!(:gitlab_subscription) { create(:gitlab_subscription, :free, namespace: group) }
let(:project) { create(:project, :public, group: group) }
it 'includes all features in global license' do
......
......@@ -280,7 +280,9 @@ describe PushRule do
end
context 'with GL.com plans' do
let(:group) { create(:group, plan: plan) }
let(:group) { create(:group) }
let(:plan) { :free }
let!(:gitlab_subscription) { create(:gitlab_subscription, plan, namespace: group) }
let(:project) { create(:project, namespace: group) }
let(:push_rule) { create(:push_rule, project: project) }
......@@ -290,19 +292,19 @@ describe PushRule do
end
context 'with a Bronze plan' do
let(:plan) { :bronze_plan }
let(:plan) { :bronze }
it_behaves_like 'an unavailable push_rule'
end
context 'with a Silver plan' do
let(:plan) { :silver_plan }
let(:plan) { :silver }
it_behaves_like 'an available push_rule'
end
context 'with a Gold plan' do
let(:plan) { :gold_plan }
let(:plan) { :gold }
it_behaves_like 'an available push_rule'
end
......
This diff is collapsed.
......@@ -246,10 +246,10 @@ describe API::V3::Github do
end
it 'filters unlicensed namespace projects' do
silver_plan = create(:silver_plan)
licensed_project = create(:project, :empty_repo, group: group)
licensed_project.add_reporter(user)
licensed_project.namespace.update!(plan_id: silver_plan.id)
create(:gitlab_subscription, :silver, namespace: licensed_project.namespace)
stub_licensed_features(jira_dev_panel_integration: true)
stub_application_setting_on_object(project, should_check_namespace_plan: true)
......
require 'spec_helper'
describe Ci::CreatePipelineService, '#execute' do
set(:namespace) { create(:namespace, plan: :gold_plan) }
set(:namespace) { create(:namespace) }
set(:gold_plan) { create(:gold_plan) }
set(:project) { create(:project, :repository, namespace: namespace) }
set(:user) { create(:user) }
......@@ -15,6 +16,8 @@ describe Ci::CreatePipelineService, '#execute' do
end
before do
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
project.add_developer(user)
stub_ci_pipeline_to_return_yaml_file
end
......@@ -31,7 +34,7 @@ describe Ci::CreatePipelineService, '#execute' do
context 'when pipeline activity limit is exceeded' do
before do
namespace.plan.update_column(:active_pipelines_limit, 2)
gold_plan.update_column(:active_pipelines_limit, 2)
create(:ci_pipeline, project: project, status: 'pending')
create(:ci_pipeline, project: project, status: 'running')
......@@ -50,7 +53,7 @@ describe Ci::CreatePipelineService, '#execute' do
context 'when pipeline size limit is exceeded' do
before do
namespace.plan.update_column(:pipeline_size_limit, 2)
gold_plan.update_column(:pipeline_size_limit, 2)
end
it 'drops pipeline without creating jobs' do
......
......@@ -67,7 +67,7 @@ describe Dashboard::Operations::ProjectsService do
before do
stub_application_setting(check_namespace_plan: true)
namespace.update!(plan: create(:gold_plan))
create(:gitlab_subscription, :gold, namespace: namespace)
end
where(:plan, :trial, :expired, :available) do
......@@ -96,18 +96,19 @@ describe Dashboard::Operations::ProjectsService do
using RSpec::Parameterized::TableSyntax
where(:check_namespace_plan, :plan, :available) do
true | :gold_plan | true
true | :silver_plan | false
true | nil | false
false | :gold_plan | true
false | :silver_plan | true
false | nil | true
true | :gold | true
true | :silver | false
true | nil | false
false | :gold | true
false | :silver | true
false | nil | true
end
with_them do
before do
stub_application_setting(check_namespace_plan: check_namespace_plan)
namespace.update!(plan: create(plan)) if plan
create(:gitlab_subscription, plan, namespace: namespace) if plan
end
if params[:available]
......
......@@ -131,8 +131,7 @@ describe Projects::CreateService, '#execute' do
context 'when licensed on a namespace' do
it 'allows enabling mirrors' do
plan = create(:gold_plan)
user.namespace.update!(plan: plan)
create(:gitlab_subscription, :gold, namespace: user.namespace)
project = create_project(user, opts)
......
......@@ -62,9 +62,11 @@ describe UpdateAllMirrorsWorker do
context 'licensed' do
def scheduled_mirror(at:, licensed:)
namespace = create(:group, :public, plan: (:bronze_plan if licensed))
namespace = create(:group, :public)
project = create(:project, :public, :mirror, namespace: namespace)
create(:gitlab_subscription, (licensed ? :bronze : :free), namespace: namespace)
project.import_state.update!(next_execution_timestamp: at)
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
project
......
require 'spec_helper'
describe UpdateMaxSeatsUsedForGitlabComSubscriptionsWorker, :postgresql do
subject { described_class.new }
let!(:user) { create(:user) }
let!(:group) { create(:group) }
let!(:bronze_plan) { create(:bronze_plan) }
let!(:early_adopter_plan) { create(:early_adopter_plan) }
let!(:gitlab_subscription) { create(:gitlab_subscription, namespace: group) }
before do
allow(Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?) { true }
group.add_developer(user)
end
shared_examples 'keeps original max_seats_used value' do
before do
gitlab_subscription.update!(subscription_attrs)
end
it 'does not update max_seats_used' do
expect { subject.perform }.not_to change { gitlab_subscription.reload.max_seats_used }
end
end
context 'with a free plan' do
let(:subscription_attrs) { { hosted_plan: nil } }
include_examples 'keeps original max_seats_used value'
end
context 'with a trial plan' do
let(:subscription_attrs) { { hosted_plan: bronze_plan, trial: true } }
include_examples 'keeps original max_seats_used value'
end
context 'with an early adopter plan' do
let(:subscription_attrs) { { hosted_plan: early_adopter_plan } }
include_examples 'keeps original max_seats_used value'
end
context 'with a paid plan' do
before do
gitlab_subscription.update!(hosted_plan: bronze_plan)
end
it 'only updates max_seats_used if active users count is greater than it' do
expect { subject.perform }.to change { gitlab_subscription.reload.max_seats_used }.to(1)
end
it 'does not update max_seats_used if active users count is lower than it' do
gitlab_subscription.update_attribute(:max_seats_used, 5)
expect { subject.perform }.not_to change { gitlab_subscription.reload.max_seats_used }
end
end
end
......@@ -6,6 +6,17 @@ module API
before { authenticate! }
helpers do
params :optional_list_params_ee do
# EE::API::Namespaces would override this helper
end
# EE::API::Namespaces would override this method
def custom_namespace_present_options
{}
end
end
prepend EE::API::Namespaces
resource :namespaces do
......@@ -14,14 +25,18 @@ module API
end
params do
optional :search, type: String, desc: "Search query for namespaces"
use :pagination
use :optional_list_params_ee
end
get do
namespaces = current_user.admin ? Namespace.all : current_user.namespaces
namespaces = namespaces.search(params[:search]) if params[:search].present?
present paginate(namespaces), with: Entities::Namespace, current_user: current_user
options = { with: Entities::Namespace, current_user: current_user }
present paginate(namespaces), options.reverse_merge(custom_namespace_present_options)
end
desc 'Get a namespace by ID' do
......
......@@ -975,9 +975,10 @@ into similar problems in the future (e.g. when new tables are created).
raise "#{model_class} does not have an ID to use for batch ranges" unless model_class.column_names.include?('id')
jobs = []
table_name = model_class.quoted_table_name
model_class.each_batch(of: batch_size) do |relation|
start_id, end_id = relation.pluck('MIN(id), MAX(id)').first
start_id, end_id = relation.pluck("MIN(#{table_name}.id), MAX(#{table_name}.id)").first
if jobs.length >= BACKGROUND_MIGRATION_JOB_BUFFER_SIZE
# Note: This code path generally only helps with many millions of rows
......
......@@ -701,6 +701,9 @@ msgstr ""
msgid "An error occurred while loading the file"
msgstr ""
msgid "An error occurred while loading the subscription details."
msgstr ""
msgid "An error occurred while making the request."
msgstr ""
......@@ -8155,6 +8158,72 @@ msgstr ""
msgid "Subscribed"
msgstr ""
msgid "SubscriptionTable|Billing"
msgstr ""
msgid "SubscriptionTable|Free"
msgstr ""
msgid "SubscriptionTable|GitLab allows you to continue using your subscription even if you exceed the number of seats you purchased. You will be required to pay for these seats upon renewal."
msgstr ""
msgid "SubscriptionTable|GitLab.com %{planName} %{suffix}"
msgstr ""
msgid "SubscriptionTable|Last invoice"
msgstr ""
msgid "SubscriptionTable|Loading subscriptions"
msgstr ""
msgid "SubscriptionTable|Manage"
msgstr ""
msgid "SubscriptionTable|Max seats used"
msgstr ""
msgid "SubscriptionTable|Next invoice"
msgstr ""
msgid "SubscriptionTable|Seats currently in use"
msgstr ""
msgid "SubscriptionTable|Seats in subscription"
msgstr ""
msgid "SubscriptionTable|Seats owed"
msgstr ""
msgid "SubscriptionTable|Subscription end date"
msgstr ""
msgid "SubscriptionTable|Subscription start date"
msgstr ""
msgid "SubscriptionTable|This is the last time the GitLab.com team was in contact with you to settle any outstanding balances."
msgstr ""
msgid "SubscriptionTable|This is the maximum number of users that have existed at the same time since this subscription started."
msgstr ""
msgid "SubscriptionTable|This is the next date when the GitLab.com team is scheduled to get in contact with you to settle any outstanding balances."
msgstr ""
msgid "SubscriptionTable|Trial"
msgstr ""
msgid "SubscriptionTable|Upgrade"
msgstr ""
msgid "SubscriptionTable|Usage"
msgstr ""
msgid "SubscriptionTable|Usage count is performed once a day at 12:00 PM."
msgstr ""
msgid "SubscriptionTable|subscription"
msgstr ""
msgid "Switch branch/tag"
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