From 41fc21285f6f3ca6806f7a047c35b5b4f0ea38ad Mon Sep 17 00:00:00 2001 From: Harsh Date: Mon, 6 Oct 2025 14:15:27 +0530 Subject: [PATCH 01/15] Add BillingUrlBuilderService for subscription upgrade URLs Introduces a new service to determine the appropriate billing URL for users to upgrade their subscriptions based on: - Current group eligibility and permissions - Number of owned groups with free/trial plans - Fallback to profile billing when multiple groups exist Includes comprehensive test coverage for all scenarios and error handling with exception tracking. Changelog: added EE: true --- .../billing_url_builder_service.rb | 56 +++++++++ .../billing_url_builder_service_spec.rb | 115 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 ee/app/services/gitlab_subscriptions/billing_url_builder_service.rb create mode 100644 ee/spec/services/gitlab_subscriptions/billing_url_builder_service_spec.rb diff --git a/ee/app/services/gitlab_subscriptions/billing_url_builder_service.rb b/ee/app/services/gitlab_subscriptions/billing_url_builder_service.rb new file mode 100644 index 00000000000000..a23f6478505b05 --- /dev/null +++ b/ee/app/services/gitlab_subscriptions/billing_url_builder_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + class BillingUrlBuilderService + def initialize(user, current_group) + @user = user + @current_group = current_group + end + + def execute + owned_groups = user.owned_groups.free_or_trial.include_gitlab_subscription + + return { show_upgrade_subscription: false } if owned_groups.empty? + + billing_url = determine_billing_url(owned_groups) + + { + show_upgrade_subscription: true, + upgrade_subscription_url: billing_url + } + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, user_id: user&.id, current_group_id: current_group&.id) + { show_upgrade_subscription: false } + end + + private + + attr_reader :user, :current_group + + def determine_billing_url(owned_groups) + return group_billings_path(current_group) if current_group_eligible_for_upgrade?(owned_groups) + + case owned_groups.count + when 1 + group_billings_path(owned_groups.first) + else + profile_billings_path + end + end + + def current_group_eligible_for_upgrade?(owned_groups) + return false unless current_group&.persisted? + + user.can?(:admin_group, current_group) && + owned_groups.include?(current_group) + end + + def group_billings_path(group) + Rails.application.routes.url_helpers.group_billings_path(group) + end + + def profile_billings_path + Rails.application.routes.url_helpers.profile_billings_path + end + end +end diff --git a/ee/spec/services/gitlab_subscriptions/billing_url_builder_service_spec.rb b/ee/spec/services/gitlab_subscriptions/billing_url_builder_service_spec.rb new file mode 100644 index 00000000000000..87d0621307ef0c --- /dev/null +++ b/ee/spec/services/gitlab_subscriptions/billing_url_builder_service_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::BillingUrlBuilderService, feature_category: :subscription_management do + let(:user) { build_stubbed(:user) } + let(:current_group) { build_stubbed(:group, path: 'current-group') } + let(:service) { described_class.new(user, current_group) } + + describe '#execute' do + context 'when user has no owned groups with free or trial plans' do + before do + allow(user).to receive_message_chain(:owned_groups, :free_or_trial, :include_gitlab_subscription).and_return([]) + end + + it 'returns show_upgrade_subscription as false' do + result = service.execute + + expect(result).to eq({ show_upgrade_subscription: false }) + end + end + + context 'when user has owned groups with free or trial plans' do + let(:owned_group1) { build_stubbed(:group, path: 'group1') } + let(:owned_group2) { build_stubbed(:group, path: 'group2') } + let(:owned_groups) { [owned_group1, owned_group2] } + + before do + allow(user).to receive_message_chain(:owned_groups, :free_or_trial, + :include_gitlab_subscription).and_return(owned_groups) + end + + context 'when current group is eligible for upgrade' do + before do + allow(current_group).to receive(:persisted?).and_return(true) + allow(user).to receive(:can?).with(:admin_group, current_group).and_return(true) + allow(owned_groups).to receive(:include?).with(current_group).and_return(true) + end + + it 'returns current group billing path' do + result = service.execute + + expect(result).to eq({ + show_upgrade_subscription: true, + upgrade_subscription_url: "/groups/#{current_group.full_path}/-/billings" + }) + end + end + + context 'when current group is not eligible for upgrade' do + before do + allow(current_group).to receive(:persisted?).and_return(true) + allow(user).to receive(:can?).with(:admin_group, current_group).and_return(false) + end + + context 'when user has exactly one owned group' do + let(:owned_groups) { [owned_group1] } + + it 'returns the single owned group billing path' do + result = service.execute + + expect(result).to eq({ + show_upgrade_subscription: true, + upgrade_subscription_url: "/groups/#{owned_group1.full_path}/-/billings" + }) + end + end + + context 'when user has multiple owned groups' do + it 'returns profile billings path' do + result = service.execute + + expect(result).to eq({ + show_upgrade_subscription: true, + upgrade_subscription_url: '/-/profile/billings' + }) + end + end + end + + context 'when current group is nil' do + let(:current_group) { nil } + + it 'returns profile billings path for single group' do + owned_groups = [owned_group1] + allow(user).to receive_message_chain(:owned_groups, :free_or_trial, + :include_gitlab_subscription).and_return(owned_groups) + + result = service.execute + + expect(result).to eq({ + show_upgrade_subscription: true, + upgrade_subscription_url: "/groups/#{owned_group1.full_path}/-/billings" + }) + end + end + end + + context 'when an error occurs in execute method' do + before do + allow(user).to receive_message_chain(:owned_groups, :free_or_trial, :include_gitlab_subscription) + .and_raise(StandardError, 'Database connection failed') + end + + it 'tracks the exception and returns safe fallback' do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(instance_of(StandardError), user_id: user.id, current_group_id: current_group.id) + + result = service.execute + + expect(result).to eq({ show_upgrade_subscription: false }) + end + end + end +end -- GitLab From 5444335bb287244f9e325e9392469c9d40e83b08 Mon Sep 17 00:00:00 2001 From: Harsh Date: Mon, 6 Oct 2025 14:21:22 +0530 Subject: [PATCH 02/15] Integrate BillingUrlBuilderService into super sidebar context Adds upgrade subscription data to the super sidebar by: - Adding upgrade_subscription_data method to SidebarsHelper with CE fallback - Overriding the method in EE to use BillingUrlBuilderService - Merging subscription data into super_sidebar_logged_in_context - Adding comprehensive test coverage for the integration --- app/helpers/sidebars_helper.rb | 6 +++++- ee/app/helpers/ee/sidebars_helper.rb | 5 +++++ ee/spec/helpers/ee/sidebars_helper_spec.rb | 18 ++++++++++++++++++ spec/helpers/sidebars_helper_spec.rb | 1 + 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 8b78cf9ff09fea..d924be6c81e78a 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -113,7 +113,7 @@ def super_sidebar_logged_in_context(user, group:, project:, panel:, panel_type:) shortcut_links: shortcut_links(user: user, project: project), track_visits_path: track_namespace_visits_path, work_items: work_items_modal_data(group, project) - }) + }.merge(upgrade_subscription_data(user, group))) end def super_sidebar_instance_version_data @@ -463,6 +463,10 @@ def admin_area_link def display_admin_area_link? current_user&.can?(:access_admin_area) end + + def upgrade_subscription_data(_user, _current_group) + { show_upgrade_subscription: false } + end end SidebarsHelper.prepend_mod_with('SidebarsHelper') diff --git a/ee/app/helpers/ee/sidebars_helper.rb b/ee/app/helpers/ee/sidebars_helper.rb index c767fec38a69e7..c82be448438ef5 100644 --- a/ee/app/helpers/ee/sidebars_helper.rb +++ b/ee/app/helpers/ee/sidebars_helper.rb @@ -91,6 +91,11 @@ def compare_plans_url(user: nil, project: nil, group: nil) private + override :upgrade_subscription_data + def upgrade_subscription_data(user, current_group) + GitlabSubscriptions::BillingUrlBuilderService.new(user, current_group).execute + end + def user_in_experiment(user) strong_memoize_with(:user_in_experiment, user) do user&.onboarding_status&.dig(:experiments)&.include?('default_pinned_nav_items') diff --git a/ee/spec/helpers/ee/sidebars_helper_spec.rb b/ee/spec/helpers/ee/sidebars_helper_spec.rb index 5166db7dc0679d..cbdaf11d662e18 100644 --- a/ee/spec/helpers/ee/sidebars_helper_spec.rb +++ b/ee/spec/helpers/ee/sidebars_helper_spec.rb @@ -20,6 +20,7 @@ allow(panel).to receive_messages(super_sidebar_menu_items: nil, super_sidebar_context_header: nil) allow(user).to receive_messages(assigned_open_issues_count: 1, assigned_open_merge_requests_count: 4, review_requested_open_merge_requests_count: 0, todos_pending_count: 3, total_merge_requests_count: 4) + allow(helper).to receive(:upgrade_subscription_data).and_return({ show_upgrade_subscription: false }) end # Tests for logged-out sidebar context, @@ -251,6 +252,22 @@ }) end + context 'when upgrade subscription data is available' do + let(:service_result) { { show_upgrade_subscription: true, upgrade_subscription_url: '/group/billing/path' } } + + before do + allow(helper).to receive(:upgrade_subscription_data).and_call_original + allow(GitlabSubscriptions::BillingUrlBuilderService).to receive(:new).with(user, group).and_return( + instance_double(GitlabSubscriptions::BillingUrlBuilderService, execute: service_result) + ) + end + + it 'includes upgrade subscription data in context' do + expect(super_sidebar_context).to include(service_result) + expect(GitlabSubscriptions::BillingUrlBuilderService).to have_received(:new).with(user, group) + end + end + describe 'for Duo agent platform widget' do before do stub_saas_features(gitlab_duo_saas_only: true) @@ -421,6 +438,7 @@ before do allow(panel).to receive_messages(super_sidebar_menu_items: nil, super_sidebar_context_header: nil) allow(helper).to receive_messages(current_user: user, current_user_mode: current_user_mode) + allow(helper).to receive(:upgrade_subscription_data).and_return({ show_upgrade_subscription: false }) end context 'when user is assigned a custom admin role' do diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb index aca29fb8af622d..651506274d3223 100644 --- a/spec/helpers/sidebars_helper_spec.rb +++ b/spec/helpers/sidebars_helper_spec.rb @@ -68,6 +68,7 @@ allow(helper).to receive(:current_user_mode).and_return(current_user_mode) allow(panel).to receive(:super_sidebar_menu_items).and_return(nil) allow(panel).to receive(:super_sidebar_context_header).and_return(nil) + allow(helper).to receive(:upgrade_subscription_data).and_return({ show_upgrade_subscription: false }) if user allow(user).to receive(:assigned_open_issues_count).and_return(1) -- GitLab From 2739a505042330506b7923e8f63c094d65b9cb3c Mon Sep 17 00:00:00 2001 From: Harsh Date: Mon, 6 Oct 2025 14:23:05 +0530 Subject: [PATCH 03/15] Add upgrade subscription item to user menu Adds a new "Upgrade subscription" menu item to the user dropdown that: - Shows when user has eligible groups for subscription upgrade - Links to the appropriate billing page based on group context - Includes proper Snowplow tracking for analytics - Includes comprehensive frontend test coverage The menu item appears conditionally based on the user's group ownership and subscription status, helping users easily find upgrade options when available. --- .../super_sidebar/components/user_menu.vue | 23 +++++++ locale/gitlab.pot | 3 + .../components/user_menu_spec.js | 64 ++++++++++++++++++- spec/frontend/super_sidebar/mock_data.js | 2 + 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index a5c59a5f82c0f2..d67596896545cd 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -37,6 +37,7 @@ export default { oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'), gitlabNext: s__('CurrentUser|Switch to GitLab Next'), startTrial: s__('CurrentUser|Start an Ultimate trial'), + upgradeSubscription: s__('CurrentUser|Upgrade subscription'), adminArea: s__('Navigation|Admin'), enterAdminMode: s__('CurrentUser|Enter Admin Mode'), leaveAdminMode: s__('CurrentUser|Leave Admin Mode'), @@ -164,6 +165,16 @@ export default { }, }; }, + upgradeSubscriptionItem() { + return { + text: this.$options.i18n.upgradeSubscription, + href: this.data.upgrade_subscription_url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'upgrade_subscription', + }, + }; + }, enterAdminModeItem() { return { text: this.$options.i18n.enterAdminMode, @@ -232,6 +243,9 @@ export default { showNotificationDot() { return this.data.pipeline_minutes?.show_notification_dot; }, + showUpgradeSubscriptionItem() { + return this.data.show_upgrade_subscription; + }, dropdownOffset() { return { mainAxis: DROPDOWN_Y_OFFSET, @@ -448,6 +462,15 @@ export default { + + + + + + { stubs: { GlEmoji, GlAvatar: true, + GlIcon: true, SetStatusModal: stubComponent(SetStatusModal), ...stubs, }, @@ -592,6 +593,67 @@ describe('UserMenu component', () => { }); }); + describe('Upgrade subscription item', () => { + let item; + + const findUpgradeSubscriptionItem = () => wrapper.findByTestId('upgrade-subscription-item'); + + describe('when show_upgrade_subscription is false', () => { + beforeEach(() => { + createWrapper({ show_upgrade_subscription: false }); + item = findUpgradeSubscriptionItem(); + }); + + it('does not render the upgrade subscription menu item', () => { + expect(item.exists()).toBe(false); + }); + }); + + describe('when show_upgrade_subscription is true', () => { + beforeEach(() => { + createWrapper({ + show_upgrade_subscription: true, + upgrade_subscription_url: '/groups/test-group/-/billings' + }); + item = findUpgradeSubscriptionItem(); + }); + + it('renders the upgrade subscription menu item', () => { + expect(item.exists()).toBe(true); + }); + + it('should render a link to upgrade subscription with correct URL', () => { + expect(item.text()).toBe(UserMenu.i18n.upgradeSubscription); + expect(item.find('a').attributes('href')).toBe('/groups/test-group/-/billings'); + }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'upgrade_subscription', + }); + }); + + it('renders with license icon', () => { + const icon = item.findComponent(GlIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('license'); + }); + }); + + describe('when upgrade subscription data is not provided', () => { + beforeEach(() => { + createWrapper(); + item = findUpgradeSubscriptionItem(); + }); + + it('does not render the upgrade subscription menu item', () => { + expect(item.exists()).toBe(false); + }); + }); + }); + describe('Admin item', () => { const findAdminLinkItem = () => wrapper.findByTestId('admin-link'); diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index 1add14c751364b..37250a36b9c8f8 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -282,6 +282,8 @@ export const userMenuMockData = { sign_out_link: invalidUrl, gitlab_com_but_not_canary: true, canary_toggle_com_url: 'https://next.gitlab.com', + show_upgrade_subscription: false, + upgrade_subscription_url: '/groups/test-group/-/billings', }; export const frecentGroupsMock = [ -- GitLab From d87a3c180d9b993d2def9d578a741eb239a8138b Mon Sep 17 00:00:00 2001 From: Harsh Date: Mon, 6 Oct 2025 16:48:34 +0530 Subject: [PATCH 04/15] Prioritize upgrade subscription over trial item in user menu - Update showTrialItem logic to hide trial when upgrade subscription is available - Add comprehensive test coverage for prioritization scenarios - Ensure upgrade subscription takes precedence over trial CTA --- .../super_sidebar/components/user_menu.vue | 7 ++++-- .../components/user_menu_spec.js | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index d67596896545cd..c73107b6ca0306 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -118,7 +118,7 @@ export default { }; }, showTrialItem() { - return this.data.trial?.has_start_trial; + return !this.showUpgradeSubscriptionItem && this.data.trial?.has_start_trial; }, editProfileItem() { return { @@ -463,7 +463,10 @@ export default { - +