From 480f20eb0bb813825cdf1b5e219c5b2d458575e5 Mon Sep 17 00:00:00 2001 From: Josianne Hyson <jhyson@gitlab.com> Date: Thu, 5 May 2022 15:29:35 +0000 Subject: [PATCH] Create service to calculate the seat count usage We want to be able to display an alert to the user when they are nearing their seat count for their subscription. Create a service that will determine if a use should be shown this alert based on a differing set of thresholds for their company size. Issue: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79563 EE: true --- ee/app/models/gitlab_subscription.rb | 4 + .../calculate_seat_count_data_service.rb | 86 +++++++++ ...k_seat_usage_alerts_eligibility_service.rb | 52 ++++++ .../subscription_portal/clients/graphql.rb | 24 +++ .../clients/graphql_spec.rb | 63 +++++++ ee/spec/models/gitlab_subscription_spec.rb | 34 ++++ .../calculate_seat_count_data_service_spec.rb | 165 ++++++++++++++++++ ...t_usage_alerts_eligibility_service_spec.rb | 72 ++++++++ 8 files changed, 500 insertions(+) create mode 100644 ee/app/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service.rb create mode 100644 ee/app/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service.rb create mode 100644 ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb create mode 100644 ee/spec/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service_spec.rb diff --git a/ee/app/models/gitlab_subscription.rb b/ee/app/models/gitlab_subscription.rb index 520dae7e7b707..1db078011b5aa 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 0000000000000..0ecefdf6ec053 --- /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 0000000000000..d14a8a72ee5db --- /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 e87d9b7a807a6..f4efa55b842c2 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 e3a9a3eed7873..e337d05016c7d 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 3c446690c68e0..ee97077bbabda 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 0000000000000..1d2e47f1dcc08 --- /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 0000000000000..851b9e462256c --- /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 -- GitLab