Set plan uid as the source of truth for gitlab subscription

What does this MR do and why?

This change sets plan uid as the source of truth for GitlabSubscription and the related SubscriptionHistory plan associations in preparation of removing the old plan_id column. While transitioning the old plan id column will continue to be populated.

This is part of an effort to move Plan to a static in memory table to support organizations.

Next steps:

  1. Update application code to only reference the new plan uid column
  2. Remove the old plan id column

References

#596996

Screenshots or screen recordings

Setting plan for group and user in admin

Bidirectional sync

Sync from plan_name_uid to plan_id

[39] pry(main)> g = Group.last
=> #<Group id:1000000 @gitlab-duo>
[40] pry(main)> g.gitlab_subscription.hosted_plan_id
=> 3
[41] pry(main)> g.gitlab_subscription.hosted_plan_name_uid = 2
=> 2 
[42] pry(main)> g.save
=> true
[43] pry(main)> g.gitlab_subscription.hosted_plan_id
=> 11 

Sync from plan_id to plan_name_uid

[44] pry(main)> g = Group.last
=> #<Group id:1000000 @gitlab-duo>
[45] pry(main)> g.gitlab_subscription.hosted_plan_name_uid
=> 2
[46] pry(main)> g.gitlab_subscription.hosted_plan_id = 3
=> 3 
[48] pry(main)> g.save
=> true
[49] pry(main)> g.gitlab_subscription.hosted_plan_name_uid
=> 4 

SubscriptionHistory Updates

Plan Association Queries

Development setup scripts

Duo Development setup console output
➜  gitlab git:(571422-gitlab-subscription-update-application-code-to-use-new-plan-associations) GITLAB_DUO_RESEED=1 bundle exec rake "gitlab:duo:setup"
Enabling feature flags....
Enabling the feature flag: agent_platform_claude_code
Enabling the feature flag: agentic_foundational_flow_tool
Enabling the feature flag: ai_flow_triggers
Enabling the feature flag: ai_flow_triggers_use_composite_identity
Enabling the feature flag: ai_prompts_v2
Enabling the feature flag: comment_temperature
Enabling the feature flag: convert_to_gl_ci_flow_registry
Enabling the feature flag: dap_external_trigger_usage_billing
Enabling the feature flag: duo_access_through_namespaces
Enabling the feature flag: duo_foundational_agents_per_agent_availability
Enabling the feature flag: duo_workflow_use_composite_identity
Enabling the feature flag: use_generic_gitlab_api_tools
Enabling the feature flag: add_ai_summary_for_new_mr
Enabling the feature flag: mcp_catalog_agent_tools
Enabling the feature flag: summarize_my_code_review
Enabling the feature flag: dap_web_search
Enabling the feature flag: ai_prompt_scanning
Enabling the feature flag: dap_git_tree_zero_option
Enabling the feature flag: duo_include_context_dependency
Enabling the feature flag: duo_include_context_issue
Enabling the feature flag: duo_include_context_local_git
Enabling the feature flag: duo_include_context_merge_request
Enabling the feature flag: duo_workflow_cloud_connector_url
Enabling the feature flag: duo_workflow_stream_during_tool_call_generation
Enabling the feature flag: lock_workflows_for_web_only
Enabling the feature flag: mcp_client
Enabling the feature flag: timeout_dap_http_requests_in_workhorse
Enabling the feature flag: ai_global_switch
Enabling the feature flag: duo_workflow_compress_checkpoint
Enabling the feature flag: duo_workflow_extended_logging
Enabling the feature flag: knowledge_graph_infra
Enabling the feature flag: use_mock_dot_api_for_usage_quota
Enabling the feature flag: dap_group_network_access_controls
Enabling the feature flag: dap_instance_network_access_controls
Enabling the feature flag: knowledge_graph
Enabling the feature flag: ai_context_compaction
Enabling the feature flag: no_duo_classic_for_duo_core_users
Enabling the feature flag: forbid_composite_identities_to_run_pipelines
Enabling the feature flag: duo_chat_binary_feedback
Enabling the feature flag: duo_code_review_group_level_instructions
Enabling the feature flag: remove_duo_flow_service_accounts_from_autocomplete_query
Enabling the feature flag: semantic_code_search_saas_ga
Enabling the feature flag: access_rest_chat
Enabling the feature flag: ai_duo_code_suggestions_switch
Enabling the feature flag: duo_code_review_dap_internal_users
Enabling the feature flag: duo_code_review_response_logging
Enabling the feature flag: duo_runner_restrictions
Enabling the feature flag: expanded_ai_logging
Enabling the feature flag: duo_developer_next_unstable
Enabling the feature flag: slack_duo_agent
================================================================================
## Running self-managed mode setup
## If you want to run .com mode, set GITLAB_SIMULATE_SAAS=1
## and re-run this script
## See https://docs.gitlab.com/ee/development/ee_features.html#simulate-a-saas-instance
## for more information.
================================================================================
Seeding GitLab Duo data...

== Filtering seed files against regexp: /gitlab_duo/

== Seed from ee/db/fixtures/development/95_gitlab_duo.rb
Destroying gitlab-duo/test project...
W, [2026-04-27T14:07:25.589724 #82693]  WARN -- : Scoped order is ignored, it's forced to be batch order.
Destroying gitlab-duo group...
Seeding resources to gitlab-duo group...
Cloning into bare repository '/tmp/git_bundle20260427-82693-fahesw/gitlab-duo/test'...
remote: Enumerating objects: 17, done.
remote: Counting objects: 100% (2/2), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 17 (delta 1), reused 0 (delta 0), pack-reused 15 (from 1)
Receiving objects: 100% (17/17), 5.17 KiB | 5.17 MiB/s, done.
Resolving deltas: 100% (3/3), done.
Enumerating objects: 17, done.
Counting objects: 100% (17/17), done.
Delta compression using up to 22 threads
Compressing objects: 100% (10/10), done.
Writing objects: 100% (17/17), 5.17 KiB | 5.17 MiB/s, done.
Total 17 (delta 3), reused 17 (delta 3), pack-reused 0

OK
== ee/db/fixtures/development/95_gitlab_duo.rb took 16.92 seconds
== Seeding took 16.92 seconds
Duo Core add-on added...
Duo Enterprise add-on added...
----------------------------------------
Setup Complete!
----------------------------------------

Visit "http://gdk.test:3000/gitlab-duo" for testing GitLab Duo features.

For more development guidelines, see https://docs.gitlab.com/ee/development/ai_features/.
Product analytics setup console output
➜  gitlab git:(571422-gitlab-subscription-update-application-code-to-use-new-plan-associations) GITLAB_SIMULATE_SAAS=1 bundle exec rake "gitlab:product_analytics:setup[somegrp]"
Validating settings....
Checking the specified group exists....
Enabling feature flags....
- product_analytics_admin_settings
- product_analytics_features
- product_analytics_billing
- custom_dashboard_storage
- product_analytics_billing_override
- observability_sass_features
- glql_es_integration
- glql_load_on_click
- contributions_analytics_dashboard
- hide_error_tracking_features
- hide_incident_management_features
- observability_features
Enabling application settings....
Activating an Ultimate license to the group....
----------------------------------------
Setup Complete!
----------------------------------------
Product Analytics is now enabled but not yet configured! To do so:

1. Setup and connect the Product Analytics Devkit to your GDK, see https://gitlab.com/gitlab-org/analytics-section/product-analytics/devkit.

2. Access Product Analytics on any project in "SomeGrp" by selecting Analyze > Analytics dashboards in the left sidebar.

Group and User Controller allows both hosted_plan_name_uid and hosted_plan_id

Screenshots

image image

image image

Verify concurrent writes

Console output
[71] pry(main)> g.gitlab_subscription
=> #<GitlabSubscription:0x00007545b9bd5dc0
 namespace_id: 132,
 hosted_plan_name_uid: 4,
 id: 21,
 created_at: "2026-04-08 14:52:45.903775000 +0000",
 updated_at: "2026-04-27 18:03:25.478540534 +0000",
 start_date: "2026-04-08",
 end_date: "2027-04-08",
 trial_ends_on: nil,
 hosted_plan_id: 3,
 max_seats_used: 1,
 seats: 3,
 trial: false,
 trial_starts_on: nil,
 auto_renew: true,
 seats_in_use: 1,
 seats_owed: 0,
 trial_extension_type: nil,
 max_seats_used_changed_at: "2026-04-09 18:00:08.768564000 +0000",
 last_seat_refresh_at: "2026-04-27 18:00:20.769131000 +0000",
 contract_overages_allowed: true>
[72] pry(main)> g.gitlab_subscription.hosted_plan_id = 7
=> 7
[73] pry(main)> g.gitlab_subscription.hosted_plan_name_uid = 2
=> 2
=> true
[75] pry(main)> g.gitlab_subscription
=> #<GitlabSubscription:0x00007545b9bd5dc0
 namespace_id: 132,
 hosted_plan_name_uid: 2,
 id: 21,
 created_at: "2026-04-08 14:52:45.903775000 +0000",
 updated_at: "2026-04-27 18:15:24.160231207 +0000",
 start_date: "2026-04-08",
 end_date: "2027-04-08",
 trial_ends_on: nil,
 hosted_plan_id: 11,
 max_seats_used: 1,
 seats: 3,
 trial: false,
 trial_starts_on: nil,
 auto_renew: true,
 seats_in_use: 1,
 seats_owed: 0,
 trial_extension_type: nil,
 max_seats_used_changed_at: "2026-04-09 18:00:08.768564000 +0000",
 last_seat_refresh_at: "2026-04-27 18:00:20.769131000 +0000",
 contract_overages_allowed: true>

How to set up and validate locally

  1. Admin UI: Change Plan for a Group
    1. Navigate to Admin > Groups > Edit for a group on a SaaS-enabled instance
    2. Confirm the plan dropdown renders all available plans (Premium, Ultimate, etc.) and "No plan"
    3. Select a plan (e.g., Premium), click Save changes
    4. Verify the group's subscription is updated and the correct plan shows on reload
    5. Verify both hosted_plan_name_uid and hosted_plan_id are correctly set in the database
  2. Admin UI: Change Plan for a User Namespace
    1. Navigate to Admin > Users > Edit for a user Confirm the plan dropdown renders correctly using plan_name_uid values
    2. Select a plan, save, and verify the subscription updates correctly
    3. Confirm the dropdown HTML element ID is now *_hosted_plan_name_uid (not *_hosted_plan_id)
  3. GitlabSubscription Model: Bidirectional Sync
    1. Create a subscription with hosted_plan_name_uid set: confirm hosted_plan_id is auto-populated via sync_hosted_plan_id

      g = Group.last
      g.gitlab_subscription.hosted_plan_id
      g.gitlab_subscription.hosted_plan_name_uid = 2
      g.save
      g.gitlab_subscription.hosted_plan_id
    2. Create a subscription with hosted_plan_id set (legacy path): confirm hosted_plan_name_uid is back-filled

      g = Group.last
      g.gitlab_subscription.hosted_plan_name_uid
      g.gitlab_subscription.hosted_plan_id = 3
      g.save
      g.gitlab_subscription.hosted_plan_name_uid
  4. hosted_plan= Setter Override
    1. Assign a Plan object via subscription.hosted_plan = plan
    g = Group.last
    g.gitlab_subscription.hosted_plan = Plan.find(2)
    g.save
    g.reload.gitlab_subscription.hosted_plan
    1. Confirm hosted_plan_name_uid is set to the correct integer enum value (not the string label like "premium") This is critical because AR's belongs_to setter reads the enum string, which would cast to 0 on an integer column
  5. SubscriptionHistory: Plan Tracking
    1. Trigger a subscription change (plan upgrade/downgrade)
    2. Confirm the history record captures both hosted_plan_name_uid and hosted_plan_id
  6. Plan Association Queries
    1. Verify Plan#hosted_subscriptions returns subscriptions via the new FK (hosted_plan_name_uid -> plan_name_uid)
    2. Verify Plan#gitlab_subscription_histories also uses the new FK
    3. Verify GitlabSubscription#hosted_plan resolves the correct plan via plan_name_uid
  7. Development Setup Scripts
    1. Run Gitlab::Duo::Developments::Setup and confirm it creates a subscription with the correct plan (uses hosted_plan: plan instead of hosted_plan_id: plan.id)

      bundle exec rake "gitlab:duo:setup"
    2. Run Gitlab::ProductAnalytics::Developments::Setup and confirm the same

       bundle exec rake "gitlab:product_analytics:setup[your_root_group_path]"
  8. Strong Parameters (Controllers)
    1. Confirm both hosted_plan_name_uid and hosted_plan_id are permitted in: Admin::GroupsController (allowed_group_params) Admin::UsersController (allowed_user_params)
    2. Attempt to submit a form with hosted_plan_name_uid and verify it is not rejected by strong params
  9. Edge Cases
    1. Concurrent writes: If hosted_plan_id and hosted_plan_name_uid are both changed in the same update, confirm hosted_plan_name_uid takes precedence (it's checked first in sync_hosted_plan_id)
    2. Factory changes: Run specs that use the :free trait to confirm hosted_plan: nil works correctly instead of the old hosted_plan_id: nil

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Related to #571422

Edited by Florian Jedelhauser

Merge request reports

Loading