diff --git a/ee/app/models/gitlab_subscription.rb b/ee/app/models/gitlab_subscription.rb index 520dae7e7b7073cde5b820c4a7affa870500a95c..1db078011b5aa904fb99d69f59a563b11fc3717a 100644 --- a/ee/app/models/gitlab_subscription.rb +++ b/ee/app/models/gitlab_subscription.rb @@ -74,6 +74,10 @@ def calculate_seats_owed [0, max_seats_used - seats].max end + def seats_remaining + [0, seats - max_seats_used.to_i].max + end + # Refresh seat related attribute (without persisting them) def refresh_seat_attributes! self.seats_in_use = calculate_seats_in_use diff --git a/ee/app/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service.rb b/ee/app/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..0ecefdf6ec053b133eafab69793056e2a5200217 --- /dev/null +++ b/ee/app/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true +# +module GitlabSubscriptions + module Reconciliations + class CalculateSeatCountDataService + include Gitlab::Utils::StrongMemoize + + # For smaller groups we want to alert when they have a set quantity of seats remaining. + # For larger groups we want to alert them when they have a percentage of seats remaining. + SEAT_COUNT_THRESHOLD_LIMITS = [ + { range: (0..15), percentage: false, value: 1 }, + { range: (16..25), percentage: false, value: 2 }, + { range: (26..99), percentage: true, value: 10 }, + { range: (100..999), percentage: true, value: 8 }, + { range: (1000..nil), percentage: true, value: 5 } + ].freeze + + attr_reader :namespace, :user + + delegate :max_seats_used, :max_seats_used_changed_at, :seats, :seats_remaining, to: :current_subscription + + def initialize(namespace:, user:) + @namespace = namespace + @user = user + end + + def execute + return unless owner_of_paid_group? && seat_count_threshold_reached? + return if user_dismissed_alert? + return unless alert_user_overage? + + { + namespace: namespace, + remaining_seat_count: seats_remaining, + seats_in_use: max_seats_used, + total_seat_count: seats + } + end + + private + + def owner_of_paid_group? + (::Gitlab::CurrentSettings.should_check_namespace_plan? && + namespace.group_namespace? && + user.can?(:admin_group, namespace) && + current_subscription).present? + end + + def adapted_remaining_user_count + return seats_remaining.fdiv(seats) * 100 if current_seat_count_threshold[:percentage] + + seats_remaining + end + + def alert_user_overage? + CheckSeatUsageAlertsEligibilityService.new(namespace: namespace).execute + end + + def current_subscription + namespace.gitlab_subscription + end + + def current_seat_count_threshold + strong_memoize(:current_seat_count_threshold) do + SEAT_COUNT_THRESHOLD_LIMITS.find do |threshold| + threshold[:range].cover?(seats) + end + end + end + + def seat_count_threshold_reached? + max_seats_used && + max_seats_used < seats && + current_seat_count_threshold[:value] >= adapted_remaining_user_count + end + + def user_dismissed_alert? + user.dismissed_callout_for_group?( + feature_name: Users::GroupCalloutsHelper::APPROACHING_SEAT_COUNT_THRESHOLD, + group: namespace, + ignore_dismissal_earlier_than: max_seats_used_changed_at + ) + end + end + end +end diff --git a/ee/app/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service.rb b/ee/app/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..d14a8a72ee5db7ac0056c9a74ac9963aa53d064d --- /dev/null +++ b/ee/app/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Service to determine if the given namespace meets the criteria to see +# alert usage overage alerts when they cross the usage thresholds. +# This service only determines eligibility from the CustomersDot application. +# +# If there is a problem querying CustomersDot, it assumes the status is false +# +# returns true, false +module GitlabSubscriptions + module Reconciliations + class CheckSeatUsageAlertsEligibilityService + def initialize(namespace:) + @namespace = namespace + end + + def execute + return false unless namespace.gitlab_subscription.present? + + eligible_for_seat_usage_alerts + end + + private + + attr_reader :namespace + + def client + Gitlab::SubscriptionPortal::Client + end + + def eligible_for_seat_usage_alerts_request + response = client.subscription_seat_usage_alerts_eligibility(namespace.id) + + return false unless response[:success] + + response[:eligible_for_seat_usage_alerts] + end + + def cache + Rails.cache + end + + def cache_key + "subscription:eligible_for_seat_usage_alerts:namespace:#{namespace.gitlab_subscription.cache_key}" + end + + def eligible_for_seat_usage_alerts + cache.fetch(cache_key, skip_nil: true, expires_in: 1.day) { eligible_for_seat_usage_alerts_request } || false + end + end + end +end diff --git a/ee/lib/gitlab/subscription_portal/clients/graphql.rb b/ee/lib/gitlab/subscription_portal/clients/graphql.rb index e87d9b7a807a6630af9e6616278e78fd07002a58..f4efa55b842c297a78b92f6878939bff39be7075 100644 --- a/ee/lib/gitlab/subscription_portal/clients/graphql.rb +++ b/ee/lib/gitlab/subscription_portal/clients/graphql.rb @@ -86,6 +86,30 @@ def plan_upgrade_offer(namespace_id) end end + def subscription_seat_usage_alerts_eligibility(namespace_id) + return error('Must provide a namespace ID') unless namespace_id + + query = <<~GQL + query($namespaceId: ID!) { + subscription(namespaceId: $namespaceId) { + eligibleForSeatUsageAlerts + } + } + GQL + + response = execute_graphql_query({ query: query, variables: { namespaceId: namespace_id } }) + + if response[:success] + { + success: true, + eligible_for_seat_usage_alerts: response.dig(:data, 'data', 'subscription', + 'eligibleForSeatUsageAlerts') + } + else + error(response.dig(:data, :errors)) + end + end + def subscription_last_term(namespace_id) return error('Must provide a namespace ID') unless namespace_id diff --git a/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb b/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb index e3a9a3eed7873a7f7893c084cda716d26589b9d6..e337d05016c7d24e0689b9d1a91b7b21ba73ed8c 100644 --- a/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb +++ b/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb @@ -204,6 +204,69 @@ end end + describe '#subscription_seat_usage_alerts_eligibility' do + let(:query) do + <<~GQL + query($namespaceId: ID!) { + subscription(namespaceId: $namespaceId) { + eligibleForSeatUsageAlerts + } + } + GQL + end + + it 'returns success when a valid license key is specified' do + expected_args = { + query: query, + variables: { + namespaceId: 'namespace-id' + } + } + + expected_response = { + success: true, + data: { + "data" => { + "subscription" => { + "eligibleForSeatUsageAlerts" => true + } + } + } + } + + expect(client).to receive(:execute_graphql_query).with(expected_args).and_return(expected_response) + + result = client.subscription_seat_usage_alerts_eligibility('namespace-id') + + expect(result).to eq({ success: true, eligible_for_seat_usage_alerts: true }) + end + + it 'returns failure when license_key is invalid' do + error = "some error" + expect(client).to receive(:execute_graphql_query).and_return( + { + success: false, + data: { + errors: error + } + } + ) + + result = client.subscription_seat_usage_alerts_eligibility('failing-namespace-id') + + expect(result).to eq({ success: false, errors: error }) + end + + context 'with no namespace_id' do + it 'returns failure' do + expect(client).not_to receive(:execute_graphql_query) + + expect(client.subscription_seat_usage_alerts_eligibility(nil)) + .to eq({ success: false, errors: 'Must provide a namespace ID' }) + end + end + end + describe '#get_plans' do subject { client.get_plans(tags: ['test-plan-id']) } diff --git a/ee/spec/models/gitlab_subscription_spec.rb b/ee/spec/models/gitlab_subscription_spec.rb index 3c446690c68e048932dc5149c2308f74ecfc7a06..ee97077bbabda781705645a210a75d92069ea728 100644 --- a/ee/spec/models/gitlab_subscription_spec.rb +++ b/ee/spec/models/gitlab_subscription_spec.rb @@ -169,6 +169,40 @@ end end + describe '#seats_remaining' do + context 'when there are more seats used than available in the subscription' do + it 'returns zero' do + subscription = build(:gitlab_subscription, seats: 10, max_seats_used: 15) + + expect(subscription.seats_remaining).to eq 0 + end + end + + context 'when seats used equals seats in subscription' do + it 'returns zero' do + subscription = build(:gitlab_subscription, seats: 10, max_seats_used: 10) + + expect(subscription.seats_remaining).to eq 0 + end + end + + context 'when there are seats left in the subscription' do + it 'returns the seat count remaining from the max seats used' do + subscription = build(:gitlab_subscription, seats: 10, max_seats_used: 5) + + expect(subscription.seats_remaining).to eq 5 + end + end + + context 'when max seat data has not yet been generated for the subscription' do + it 'returns the seat count of the subscription' do + subscription = build(:gitlab_subscription, seats: 10, max_seats_used: nil) + + expect(subscription.seats_remaining).to eq 10 + end + end + end + describe '#refresh_seat_attributes!' do subject(:gitlab_subscription) { create(:gitlab_subscription, seats: 3, max_seats_used: 2) } diff --git a/ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb b/ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1d2e47f1dcc0867a748712a4859fe711ae23b0da --- /dev/null +++ b/ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::Reconciliations::CalculateSeatCountDataService, :saas do + using RSpec::Parameterized::TableSyntax + + describe '#execute' do + let_it_be(:user) { create(:user) } + + let(:should_check_namespace_plan) { true } + + before do + allow_next_instance_of(GitlabSubscriptions::Reconciliations::CheckSeatUsageAlertsEligibilityService) do |service| + expect(service).to receive(:execute).and_return(alert_user_overage) + end + + allow(::Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?).and_return(should_check_namespace_plan) + end + + subject(:execute_service) { described_class.new(namespace: root_ancestor, user: user).execute } + + context 'with no subscription' do + let(:root_ancestor) { create(:group) } + let(:alert_user_overage) { true } + + before do + root_ancestor.add_owner(user) + end + + it { is_expected.to be nil } + end + + context 'when conditions are not met' do + let(:max_seats_used) { 9 } + + before do + create( + :gitlab_subscription, + namespace: root_ancestor, + plan_code: Plan::ULTIMATE, + seats: 10, + max_seats_used: max_seats_used + ) + end + + context 'when it is not SaaS' do + let(:alert_user_overage) { true } + let(:root_ancestor) { create(:group) } + let(:should_check_namespace_plan) { false } + + before do + root_ancestor.add_owner(user) + end + + it { is_expected.to be nil } + end + + context 'when namespace is not a group' do + let(:alert_user_overage) { true } + let(:root_ancestor) { create(:namespace, :with_namespace_settings) } + + it { is_expected.to be nil } + end + + context 'when the alert was dismissed' do + let(:alert_user_overage) { true } + let(:root_ancestor) { create(:group) } + + before do + allow(user).to receive(:dismissed_callout_for_group?).and_return(true) + root_ancestor.add_owner(user) + end + + it { is_expected.to be nil } + end + + context 'when the user does not have admin rights to the group' do + let(:alert_user_overage) { true } + let(:root_ancestor) { create(:group) } + + before do + root_ancestor.add_developer(user) + end + + it { is_expected.to be nil } + end + + context 'when the subscription is not eligible for usage alerts' do + let(:alert_user_overage) { false } + let(:root_ancestor) { create(:group) } + + before do + root_ancestor.add_owner(user) + end + + it { is_expected.to be nil } + end + + context 'when max seats used are more than the subscription seats' do + let(:alert_user_overage) { true } + let(:max_seats_used) { 11 } + let(:root_ancestor) { create(:group) } + + before do + root_ancestor.add_owner(user) + end + + it { is_expected.to be nil } + end + end + + context 'with threshold limits' do + let_it_be(:alert_user_overage) { true } + let_it_be(:root_ancestor) { create(:group) } + + before do + create( + :gitlab_subscription, + namespace: root_ancestor, + plan_code: Plan::ULTIMATE, + seats: seats, + max_seats_used: max_seats_used + ) + + root_ancestor.add_owner(user) + end + + context 'when limits are not met' do + where(:seats, :max_seats_used) do + 15 | 13 + 24 | 20 + 35 | 29 + 100 | 90 + 1000 | 949 + end + + with_them do + it { is_expected.to be nil } + end + end + + context 'when limits are met' do + where(:seats, :max_seats_used) do + 15 | 14 + 24 | 22 + 35 | 32 + 100 | 93 + 1000 | 950 + end + + with_them do + it { + is_expected.to eq({ + namespace: root_ancestor, + remaining_seat_count: [seats - max_seats_used, 0].max, + seats_in_use: max_seats_used, + total_seat_count: seats + }) + } + end + end + end + end +end diff --git a/ee/spec/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service_spec.rb b/ee/spec/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..851b9e462256c38e6a0531db8151df257597c89d --- /dev/null +++ b/ee/spec/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::Reconciliations::CheckSeatUsageAlertsEligibilityService, + :use_clean_rails_memory_store_caching, :saas do + using RSpec::Parameterized::TableSyntax + + describe '#execute' do + let_it_be(:namespace) { create(:namespace_with_plan) } + + let(:cache_key) do + "subscription:eligible_for_seat_usage_alerts:namespace:#{namespace.gitlab_subscription.cache_key}" + end + + subject(:execute_service) { described_class.new(namespace: namespace).execute } + + where(:eligible_for_seat_usage_alerts, :expected_response) do + true | true + false | false + end + + with_them do + let(:response) { { success: true, eligible_for_seat_usage_alerts: eligible_for_seat_usage_alerts } } + + before do + allow(Gitlab::SubscriptionPortal::Client) + .to receive(:subscription_seat_usage_alerts_eligibility) + .and_return(response) + end + + it 'returns the correct value' do + expect(execute_service).to eq expected_response + end + + it 'caches the query response' do + expect(Rails.cache).to receive(:fetch).with(cache_key, skip_nil: true, expires_in: 1.day).and_call_original + + execute_service + end + end + + context 'with an unsuccessful CustomersDot query' do + it 'assumes no future renewal' do + allow(Gitlab::SubscriptionPortal::Client).to receive(:subscription_seat_usage_alerts_eligibility).and_return({ + success: false + }) + + expect(execute_service).to be false + end + end + + context 'when called with a group' do + let(:namespace) { create(:group_with_plan) } + + it 'uses the namespace id' do + expect(Gitlab::SubscriptionPortal::Client) + .to receive(:subscription_seat_usage_alerts_eligibility) + .with(namespace.id) + .and_return({}) + + execute_service + end + end + + context 'when the namespace has no plan' do + let(:namespace) { build(:group) } + + it { is_expected.to be false } + end + end +end