From 0c6d62df34eb817afb9d68feafc4f0e2aa3aa109 Mon Sep 17 00:00:00 2001
From: Andrei Stoicescu <astoicescu@gitlab.com>
Date: Mon, 25 Jan 2021 23:20:31 +0000
Subject: [PATCH] Add graphQL connection

Add service to get plan upgrade info
Add specs for new helper methods
---
 ee/app/assets/stylesheets/pages/billings.scss | 115 +++++++++++++++++-
 ee/app/helpers/billing_plans_helper.rb        |  49 +++++++-
 .../plan_upgrade_service.rb                   |  26 ++++
 .../shared/billings/_billing_plan.html.haml   | 105 ++++++++++------
 .../billings/_billing_plan_header.html.haml   |   2 +-
 .../shared/billings/_billing_plans.html.haml  |   6 +-
 ee/lib/gitlab/subscription_portal/client.rb   |  29 +++++
 .../features/billings/billing_plans_spec.rb   |   6 +-
 ee/spec/features/groups/billing_spec.rb       |   2 +
 ee/spec/helpers/billing_plans_helper_spec.rb  | 110 ++++++++++++++---
 .../gitlab/subscription_portal/client_spec.rb |  96 +++++++++++++++
 .../fetch_subscription_plans_service_spec.rb  |   2 +-
 .../plan_upgrade_service_spec.rb              |  63 ++++++++++
 .../helpers/subscription_portal_helpers.rb    |  37 ++++++
 locale/gitlab.pot                             |  18 ++-
 15 files changed, 592 insertions(+), 74 deletions(-)
 create mode 100644 ee/app/services/gitlab_subscriptions/plan_upgrade_service.rb
 create mode 100644 ee/spec/services/gitlab_subscriptions/plan_upgrade_service_spec.rb
 create mode 100644 ee/spec/support/helpers/subscription_portal_helpers.rb

diff --git a/ee/app/assets/stylesheets/pages/billings.scss b/ee/app/assets/stylesheets/pages/billings.scss
index bf784cee1d44..0a3a72f7776d 100644
--- a/ee/app/assets/stylesheets/pages/billings.scss
+++ b/ee/app/assets/stylesheets/pages/billings.scss
@@ -19,21 +19,81 @@
   }
 }
 
+$gutter-small: $gl-spacing-scale-6;
+$gutter: $gl-spacing-scale-7;
+$badge-height: $gl-spacing-scale-7;
+
 .billing-plans {
+  // This color is not part of the GitLab-UI/Pajamas specifications.
+  // We're using it only for marketing purposes
+  $highlight-color: #6e49cb;
+
+  margin-bottom: $gutter-small;
+
+  > * + * {
+    margin-top: $gutter-small;
+  }
+
+  .card-wrapper-has-badge {
+    .card {
+      @include gl-border-1;
+      @include gl-border-solid;
+      @include gl-rounded-top-left-none;
+      @include gl-rounded-top-right-none;
+
+      border-color: $highlight-color;
+    }
+  }
+
+  .card-badge {
+    @include gl-rounded-top-left-base;
+    @include gl-rounded-top-right-base;
+    @include gl-font-weight-bold;
+    @include gl-px-5;
+    @include gl-text-white;
+
+    background-color: $highlight-color;
+
+    // These border radii values are not defined in gitlab-ui,
+    // but they are consistent with the startup-*.scss .card overrides
+    border-top-left-radius: 0.25rem;
+    border-top-right-radius: 0.25rem;
+
+    line-height: $badge-height;
+
+    &-text {
+      @include gl-display-block;
+      @include gl-text-truncate;
+    }
+  }
+
   .card {
+    @include gl-mb-0;
+
     &-active {
       background-color: $gray-light;
     }
 
     .card-body {
-      .price-per-month {
+      .price-description {
+        align-items: center;
         display: flex;
         flex-direction: row;
         color: $blue-500;
-        font-size: 48px;
+        font-size: 45px;
         font-weight: $gl-font-weight-bold;
         line-height: 1;
 
+        .price-rebate {
+          color: $blue-400;
+          font-size: 20px;
+          text-decoration: line-through;
+        }
+
+        .price-cut {
+          text-decoration: line-through;
+        }
+
         .conditions {
           list-style: none;
           font-size: $gl-font-size-large;
@@ -42,15 +102,62 @@
         }
       }
 
-      .price-per-year {
+      .price-conclusion {
+        @include gl-font-base;
         color: $blue-500;
-        font-size: $gl-font-size-small;
         font-weight: $gl-font-weight-bold;
       }
     }
   }
 }
 
+@media (min-width: $breakpoint-md) {
+  .billing-plans {
+    @include gl-display-flex;
+    @include gl-flex-wrap;
+    @include gl-justify-content-space-between;
+
+    > * + * {
+      @include gl-mt-0;
+    }
+
+    .card-wrapper {
+      margin-bottom: $gutter-small;
+      padding-top: $badge-height;
+      width: calc(50% - #{$gutter-small} / 2);
+
+      &-has-badge {
+        @include gl-pt-0;
+
+        .card {
+          height: calc(100% - #{$badge-height});
+        }
+      }
+    }
+
+    .card {
+      @include gl-h-full;
+    }
+  }
+}
+
+@media (min-width: $breakpoint-lg) {
+  .billing-plans {
+    flex-wrap: nowrap;
+
+    > * + * {
+      margin-left: $gutter;
+    }
+
+    .card-wrapper {
+      @include gl-flex-fill-1;
+      @include gl-mb-0;
+      @include gl-overflow-hidden;
+      @include gl-w-auto;
+    }
+  }
+}
+
 .subscription-table {
   .flex-grid {
     .grid-cell {
diff --git a/ee/app/helpers/billing_plans_helper.rb b/ee/app/helpers/billing_plans_helper.rb
index b879dc61a4fd..365908e6a20c 100644
--- a/ee/app/helpers/billing_plans_helper.rb
+++ b/ee/app/helpers/billing_plans_helper.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 module BillingPlansHelper
+  include Gitlab::Utils::StrongMemoize
+
   def subscription_plan_info(plans_data, current_plan_code)
     current_plan = plans_data.find { |plan| plan.code == current_plan_code && plan.current_subscription_plan? }
     current_plan || plans_data.find { |plan| plan.code == current_plan_code }
@@ -10,6 +12,28 @@ def number_to_plan_currency(value)
     number_to_currency(value, unit: '$', strip_insignificant_zeros: true, format: "%u%n")
   end
 
+  def upgrade_offer_type(namespace, plan)
+    return :no_offer if namespace.actual_plan_name != Plan::BRONZE || !offer_from_previous_tier?(namespace.id, plan.id)
+
+    upgrade_for_free?(namespace.id) ? :upgrade_for_free : :upgrade_for_offer
+  end
+
+  def has_upgrade?(upgrade_offer)
+    upgrade_offer == :upgrade_for_free || upgrade_offer == :upgrade_for_offer
+  end
+
+  def show_contact_sales_button?(purchase_link_action, upgrade_offer)
+    return false unless purchase_link_action == 'upgrade'
+
+    upgrade_offer == :upgrade_for_offer ||
+      (experiment_enabled?(:contact_sales_btn_in_app) && upgrade_offer == :no_offer)
+  end
+
+  def show_upgrade_button?(purchase_link_action, upgrade_offer)
+    purchase_link_action == 'upgrade' &&
+      (upgrade_offer == :no_offer || upgrade_offer == :upgrade_for_free)
+  end
+
   def subscription_plan_data_attributes(namespace, plan)
     return {} unless namespace
 
@@ -34,11 +58,6 @@ def use_new_purchase_flow?(namespace)
     namespace.group? && (namespace.actual_plan_name == Plan::FREE || namespace.trial_active?)
   end
 
-  def show_contact_sales_button?(purchase_link_action)
-    experiment_enabled?(:contact_sales_btn_in_app) &&
-      purchase_link_action == 'upgrade'
-  end
-
   def experiment_tracking_data_for_button_click(button_label)
     return {} unless Gitlab::Experimentation.active?(:contact_sales_btn_in_app)
 
@@ -139,4 +158,24 @@ def plan_renew_url(group)
   def billable_seats_href(group)
     group_seat_usage_path(group)
   end
+
+  def offer_from_previous_tier?(namespace_id, plan_id)
+    upgrade_plan_id = upgrade_plan_data(namespace_id)[:upgrade_plan_id]
+
+    return false unless upgrade_plan_id
+
+    upgrade_plan_id == plan_id
+  end
+
+  def upgrade_for_free?(namespace_id)
+    !!upgrade_plan_data(namespace_id)[:upgrade_for_free]
+  end
+
+  def upgrade_plan_data(namespace_id)
+    strong_memoize(:upgrade_plan_data) do
+      GitlabSubscriptions::PlanUpgradeService
+        .new(namespace_id: namespace_id)
+        .execute
+    end
+  end
 end
diff --git a/ee/app/services/gitlab_subscriptions/plan_upgrade_service.rb b/ee/app/services/gitlab_subscriptions/plan_upgrade_service.rb
new file mode 100644
index 000000000000..9a8fde941515
--- /dev/null
+++ b/ee/app/services/gitlab_subscriptions/plan_upgrade_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module GitlabSubscriptions
+  class PlanUpgradeService
+    def initialize(namespace_id:)
+      @namespace_id = namespace_id
+    end
+
+    def execute
+      result = client.plan_upgrade_offer(@namespace_id)
+
+      plan_id = result[:assisted_upgrade_plan_id] || result[:free_upgrade_plan_id] unless result[:eligible_for_free_upgrade].nil?
+
+      {
+         upgrade_for_free: result[:eligible_for_free_upgrade],
+         upgrade_plan_id: plan_id
+       }
+    end
+
+    private
+
+    def client
+      Gitlab::SubscriptionPortal::Client
+    end
+  end
+end
diff --git a/ee/app/views/shared/billings/_billing_plan.html.haml b/ee/app/views/shared/billings/_billing_plan.html.haml
index 0f6bb10323fc..1ef842eac907 100644
--- a/ee/app/views/shared/billings/_billing_plan.html.haml
+++ b/ee/app/views/shared/billings/_billing_plan.html.haml
@@ -1,52 +1,79 @@
 - purchase_link = plan.purchase_link
 - plan_name = plan.name
 - show_deprecated_plan = ::Feature.enabled?(:hide_deprecated_billing_plans) && plan.deprecated?
+- has_upgrade = has_upgrade?(plan_offer_type)
 - if show_deprecated_plan
   - plan_name      += ' (Legacy)'
   - faq_link_url   = 'https://about.gitlab.com/gitlab-com/#faq'
   - faq_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: faq_link_url }
 
-.card.h-100{ class: ("card-active" if is_current || show_deprecated_plan) }
-  .card-header.gl-font-weight-bold.d-flex.flex-row.justify-content-between.flex-wrap
-    %div
-      = plan_name
-    - if is_current
-      .text-muted
-        = _("Current Plan")
+.card-wrapper{ class: ("card-wrapper-has-badge" if has_upgrade) }
+  - if has_upgrade
+    .card-badge
+      %span.card-badge-text
+        - case plan_offer_type
+        - when :upgrade_for_free
+          = s_("BillingPlans|Free upgrade!")
+        - else
+          = _("Upgrade offers available!")
+  .card{ class: ("card-active" if is_current || show_deprecated_plan) }
+    .card-header.gl-font-weight-bold.d-flex.flex-row.justify-content-between.flex-wrap
+      %div
+        = plan_name
+      - if is_current
+        .text-muted
+          = s_("BillingPlans|Current Plan")
 
-  .card-body
-    - if show_deprecated_plan
-      = s_("The %{plan_name} is no longer available to purchase. For more information about how this will impact you, check our %{faq_link_start}frequently asked questions%{faq_link_end}.").html_safe % { plan_name: plan.name, faq_link_start: faq_link_start, faq_link_end: '</a>'.html_safe }
-    - else
-      .price-per-month
-        .gl-mr-2
-          = number_to_plan_currency(plan.price_per_month)
+    .card-body
+      - if show_deprecated_plan
+        = s_("The %{plan_name} is no longer available to purchase. For more information about how this will impact you, check our %{faq_link_start}frequently asked questions%{faq_link_end}.").html_safe % { plan_name: plan.name, faq_link_start: faq_link_start, faq_link_end: '</a>'.html_safe }
+      - else
+        .price-description
+          .gl-mr-2.gl-display-flex.gl-align-items-center
+            - case plan_offer_type
+            - when :upgrade_for_free
+              %span.gl-mr-3.price-rebate
+                = number_to_plan_currency(plan.price_per_month)
+              %span
+                = number_to_plan_currency(plan.upgrade_price_per_month)
+            - when :upgrade_for_offer
+              %span.price-cut
+                = number_to_plan_currency(plan.price_per_month)
+            - else
+              %span
+                = number_to_plan_currency(plan.price_per_month)
 
-        %ul.conditions.gl-p-0.gl-my-auto
-          %li= s_("BillingPlans|per user")
-          %li= s_("BillingPlans|monthly")
-      .price-per-year.text-left{ class: ("invisible" unless plan.price_per_year > 0) }
-        - price_per_year = number_to_plan_currency(plan.price_per_year)
-        = s_("BillingPlans|billed annually at %{price_per_year}") % { price_per_year: price_per_year }
+          %ul.conditions.gl-p-0.gl-my-auto
+            %li= s_("BillingPlans|per user")
+            %li= s_("BillingPlans|monthly")
+        .price-conclusion{ class: ("invisible" unless plan.price_per_year > 0) }
+          - case plan_offer_type
+          - when :upgrade_for_free
+            = s_("BillingPlans|for the remainder of your subscription")
+          - else
+            - price_per_year = number_to_plan_currency(plan.price_per_year)
+            = s_("BillingPlans|billed annually at %{price_per_year}") % { price_per_year: price_per_year }
+        %hr.gl-my-3
 
-      %hr.gl-my-3
+        %ul.unstyled-list
+          - plan_feature_list(plan).each do |feature|
+            - feature_class = "gl-p-0!"
+            - if feature.highlight
+              - feature_class += " gl-font-weight-bold"
+            %li{ class: "#{feature_class}" }
+              = feature.title
+          %li.gl-p-0.gl-pt-3
+            - if plan.about_page_href
+              = link_to s_("BillingPlans|See all %{plan_name} features") % { plan_name: plan.name }, EE::SUBSCRIPTIONS_COMPARISON_URL
 
-      %ul.unstyled-list
-        - plan_feature_list(plan).each do |feature|
-          - feature_class = "gl-p-0!"
-          - if feature.highlight
-            - feature_class += " gl-font-weight-bold"
-          %li{ class: "#{feature_class}" }
-            = feature.title
-        %li.gl-p-0.gl-pt-3
-          - if plan.about_page_href
-            = link_to s_("BillingPlans|See all %{plan_name} features") % { plan_name: plan.name }, EE::SUBSCRIPTIONS_COMPARISON_URL
-
-  - if purchase_link
     .card-footer
-      .float-right{ class: ("invisible" unless purchase_link.action == 'upgrade' || is_current) }
-        - if show_contact_sales_button?(purchase_link.action)
-          = link_to s_('BillingPlan|Contact sales'), "#{contact_sales_url}?test=inappcontactsales#{plan.code}", class: "btn btn-success-secondary gl-button", data: { **experiment_tracking_data_for_button_click('contact_sales') }
-        - cta_class = '-new' if use_new_purchase_flow?(namespace)
-        - upgrade_button_classes = upgrade_button_css_classes(namespace, plan, is_current)
-        = link_to s_('BillingPlan|Upgrade'), plan_purchase_or_upgrade_url(namespace, plan), class: "#{upgrade_button_classes} billing-cta-purchase#{cta_class}", data: { **experiment_tracking_data_for_button_click('upgrade') }
+      - cta_class = '-new' if use_new_purchase_flow?(namespace)
+      - upgrade_button_classes = upgrade_button_css_classes(namespace, plan, is_current)
+      - upgrade_button_text = plan_offer_type === :upgrade_for_free ? s_('BillingPlan|Upgrade for free') : s_('BillingPlan|Upgrade')
+      - show_upgrade_button = show_upgrade_button?(purchase_link.action, plan_offer_type)
+      - show_contact_sales_button = show_contact_sales_button?(purchase_link.action, plan_offer_type)
+      .gl-min-h-7.gl-display-flex.gl-flex-wrap.gl-justify-content-end
+        - if show_contact_sales_button
+          = link_to s_('BillingPlan|Contact sales'), "#{contact_sales_url}?test=inappcontactsales#{plan.code}", class: [("btn gl-button"), (show_upgrade_button ? "btn-success-secondary" : "btn-success")], data: { **experiment_tracking_data_for_button_click('contact_sales') }
+        - if show_upgrade_button
+          = link_to upgrade_button_text, plan_purchase_or_upgrade_url(namespace, plan), class: "#{upgrade_button_classes} billing-cta-purchase#{cta_class} gl-ml-3", data: { **experiment_tracking_data_for_button_click('upgrade') }
diff --git a/ee/app/views/shared/billings/_billing_plan_header.html.haml b/ee/app/views/shared/billings/_billing_plan_header.html.haml
index c1df7a44b06a..c610a5ff1d8f 100644
--- a/ee/app/views/shared/billings/_billing_plan_header.html.haml
+++ b/ee/app/views/shared/billings/_billing_plan_header.html.haml
@@ -3,7 +3,7 @@
 - if namespace_for_user
   = render_if_exists 'trials/banner', namespace: namespace
 
-.billing-plan-header.content-block.center
+.billing-plan-header.content-block.center.gl-mb-5
   .billing-plan-logo
     - if namespace_for_user
       .avatar-container.s96.home-panel-avatar.gl-mr-3.float-none.mx-auto.mb-4.mt-1
diff --git a/ee/app/views/shared/billings/_billing_plans.html.haml b/ee/app/views/shared/billings/_billing_plans.html.haml
index 4dbf3c712694..1bcac338f91f 100644
--- a/ee/app/views/shared/billings/_billing_plans.html.haml
+++ b/ee/app/views/shared/billings/_billing_plans.html.haml
@@ -7,12 +7,12 @@
 - if show_plans?(namespace)
   - plans = billing_available_plans(plans_data, current_plan)
 
-  .billing-plans.gl-mt-5.row.justify-content-center
+  .billing-plans
     - plans.each do |plan|
       - is_default_plan = current_plan.nil? && plan.default?
       - is_current = plan.code == current_plan&.code || is_default_plan
-      .col-md-6.col-lg-3.gl-mb-5
-        = render 'shared/billings/billing_plan', namespace: namespace, plan: plan, is_current: is_current
+      = render 'shared/billings/billing_plan', namespace: namespace, plan: plan, is_current: is_current,
+                                               plan_offer_type: upgrade_offer_type(namespace, plan)
 
 - if namespace.gitlab_subscription&.has_a_paid_hosted_plan?
   .center.gl-mb-7
diff --git a/ee/lib/gitlab/subscription_portal/client.rb b/ee/lib/gitlab/subscription_portal/client.rb
index 4f358a936c9d..89f936ddd8b6 100644
--- a/ee/lib/gitlab/subscription_portal/client.rb
+++ b/ee/lib/gitlab/subscription_portal/client.rb
@@ -45,6 +45,35 @@ def activate(activation_code)
           end
         end
 
+        def plan_upgrade_offer(namespace_id)
+          query = <<~GQL
+            {
+              subscription(namespaceId: "#{namespace_id}") {
+                eoaStarterBronzeEligible
+                assistedUpgradePlanId
+                freeUpgradePlanId
+              }
+            }
+          GQL
+
+          response = http_post("graphql", admin_headers, { query: query }).dig(:data)
+
+          if response['errors'].blank?
+            eligible = response.dig('data', 'subscription', 'eoaStarterBronzeEligible')
+            assisted_upgrade = response.dig('data', 'subscription', 'assistedUpgradePlanId')
+            free_upgrade = response.dig('data', 'subscription', 'freeUpgradePlanId')
+
+            {
+              success: true,
+              eligible_for_free_upgrade: eligible,
+              assisted_upgrade_plan_id: assisted_upgrade,
+              free_upgrade_plan_id: free_upgrade
+            }
+          else
+            { success: false }
+          end
+        end
+
         private
 
         def http_get(path, headers)
diff --git a/ee/spec/features/billings/billing_plans_spec.rb b/ee/spec/features/billings/billing_plans_spec.rb
index 01d522ff91e7..53bfcafa1f9f 100644
--- a/ee/spec/features/billings/billing_plans_spec.rb
+++ b/ee/spec/features/billings/billing_plans_spec.rb
@@ -4,6 +4,7 @@
 
 RSpec.describe 'Billing plan pages', :feature do
   include StubRequests
+  include SubscriptionPortalHelpers
 
   let(:user) { create(:user) }
   let(:namespace) { user.namespace }
@@ -22,6 +23,7 @@
     stub_experiment_for_subject(contact_sales_btn_in_app: true)
     stub_full_request("#{EE::SUBSCRIPTIONS_URL}/gitlab_plans?plan=#{plan.name}&namespace_id=#{namespace.id}")
       .to_return(status: 200, body: plans_data.to_json)
+    stub_eoa_eligibility_request(namespace.id)
     stub_application_setting(check_namespace_plan: true)
     allow(Gitlab).to receive(:com?) { true }
     gitlab_sign_in(user)
@@ -159,8 +161,7 @@ def external_upgrade_url(namespace, plan)
             expect(action).not_to have_link('Upgrade')
             expect(action).not_to have_css('.disabled')
           when 'current_plan'
-            expect(action).to have_link('Upgrade')
-            expect(action).to have_css('.disabled')
+            expect(action).not_to have_link('Upgrade')
           when 'upgrade'
             expect(action).to have_link('Upgrade')
             expect(action).not_to have_css('.disabled')
@@ -254,7 +255,6 @@ def external_upgrade_url(namespace, plan)
             expect(action).not_to have_link('Upgrade')
             expect(action).not_to have_css('.disabled')
           when 'current_plan'
-            expect(action).to have_link('Upgrade')
             expect(action).not_to have_css('.disabled')
           when 'upgrade'
             expect(action).to have_link('Upgrade')
diff --git a/ee/spec/features/groups/billing_spec.rb b/ee/spec/features/groups/billing_spec.rb
index bef142e59399..226603e167cc 100644
--- a/ee/spec/features/groups/billing_spec.rb
+++ b/ee/spec/features/groups/billing_spec.rb
@@ -4,6 +4,7 @@
 
 RSpec.describe 'Groups > Billing', :js do
   include StubRequests
+  include SubscriptionPortalHelpers
 
   let_it_be(:user) { create(:user) }
   let_it_be(:group) { create(:group) }
@@ -18,6 +19,7 @@ def subscription_table
   end
 
   before do
+    stub_eoa_eligibility_request(group.id)
     stub_full_request("#{EE::SUBSCRIPTIONS_URL}/gitlab_plans?plan=#{plan}&namespace_id=#{group.id}")
       .with(headers: { 'Accept' => 'application/json' })
       .to_return(status: 200, body: File.new(Rails.root.join('ee/spec/fixtures/gitlab_com_plans.json')))
diff --git a/ee/spec/helpers/billing_plans_helper_spec.rb b/ee/spec/helpers/billing_plans_helper_spec.rb
index 4e71fe280412..931510dd9930 100644
--- a/ee/spec/helpers/billing_plans_helper_spec.rb
+++ b/ee/spec/helpers/billing_plans_helper_spec.rb
@@ -8,7 +8,7 @@
 
     let(:group) { build(:group) }
     let(:plan) do
-      Hashie::Mash.new(id: 'external-paid-plan-hash-code', name: 'Bronze Plan')
+      OpenStruct.new(id: 'external-paid-plan-hash-code', name: 'Bronze Plan')
     end
 
     context 'when group and plan with ID present' do
@@ -61,7 +61,7 @@
     end
 
     context 'when plan with ID not present' do
-      let(:plan) { Hashie::Mash.new(id: nil, name: 'Bronze Plan') }
+      let(:plan) { OpenStruct.new(id: nil, name: 'Bronze Plan') }
 
       it 'returns data attributes without upgrade href' do
         add_seats_href = "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/extra_seats"
@@ -149,16 +149,77 @@
     end
   end
 
+  describe '#upgrade_offer_type' do
+    using RSpec::Parameterized::TableSyntax
+
+    let(:plan) { OpenStruct.new({ id: '123456789' }) }
+
+    context 'when plan has a valid property' do
+      where(:plan_name, :for_free, :plan_id, :result) do
+        Plan::BRONZE | true  | '123456789'  | :upgrade_for_free
+        Plan::BRONZE | true  | '987654321'  | :no_offer
+        Plan::BRONZE | true  | nil          | :no_offer
+        Plan::BRONZE | false | '123456789'  | :upgrade_for_offer
+        Plan::BRONZE | false | nil          | :no_offer
+        Plan::BRONZE | nil   | nil          | :no_offer
+        Plan::SILVER | nil   | nil          | :no_offer
+        nil          | true  | nil          | :no_offer
+      end
+
+      with_them do
+        let(:namespace) do
+          OpenStruct.new(
+            {
+              actual_plan_name: plan_name,
+              id: '000000000'
+            }
+          )
+        end
+
+        before do
+          allow_next_instance_of(GitlabSubscriptions::PlanUpgradeService) do |instance|
+            expect(instance).to receive(:execute).once.and_return({
+             upgrade_for_free: for_free,
+             upgrade_plan_id: plan_id
+            })
+          end
+        end
+
+        subject { helper.upgrade_offer_type(namespace, plan) }
+
+        it { is_expected.to eq(result) }
+      end
+    end
+  end
+
+  describe '#has_upgrade?' do
+    using RSpec::Parameterized::TableSyntax
+
+    where(:offer_type, :result) do
+      :no_offer          | false
+      :upgrade_for_free  | true
+      :upgrade_for_offer | true
+    end
+
+    with_them do
+      subject { helper.has_upgrade?(offer_type) }
+
+      it { is_expected.to eq(result) }
+    end
+  end
+
   describe '#show_contact_sales_button?' do
     using RSpec::Parameterized::TableSyntax
 
-    where(:experiment_enabled, :link_action, :result) do
-      true | 'downgrade' | false
-      true | 'current' | false
-      true | 'upgrade' | true
-      false | 'downgrade' | false
-      false | 'current' | false
-      false | 'upgrade' | false
+    where(:experiment_enabled, :link_action, :upgrade_offer, :result) do
+      true  | 'upgrade'     | :no_offer           | true
+      true  | 'upgrade'     | :upgrade_for_offer  | true
+      true  | 'no_upgrade'  | :no_offer           | false
+      true  | 'no_upgrade'  | :upgrade_for_offer  | false
+      false | 'upgrade'     | :no_offer           | false
+      false | 'upgrade'     | :upgrade_for_offer  | true
+      false | 'no_upgrade'  | :no_offer           | false
+      false | 'no_upgrade'  | :upgrade_for_offer  | false
     end
 
     with_them do
@@ -166,7 +227,26 @@
         allow(helper).to receive(:experiment_enabled?).with(:contact_sales_btn_in_app).and_return(experiment_enabled)
       end
 
-      subject { helper.show_contact_sales_button?(link_action) }
+      subject { helper.show_contact_sales_button?(link_action, upgrade_offer) }
+
+      it { is_expected.to eq(result) }
+    end
+  end
+
+  describe '#show_upgrade_button?' do
+    using RSpec::Parameterized::TableSyntax
+
+    where(:link_action, :upgrade_offer, :result) do
+      'upgrade'     | :no_offer          | true
+      'upgrade'     | :upgrade_for_free  | true
+      'upgrade'     | :upgrade_for_offer | false
+      'no_upgrade'  | :no_offer          | false
+      'no_upgrade'  | :upgrade_for_free  | false
+      'no_upgrade'  | :upgrade_for_offer | false
+    end
+
+    with_them do
+      subject { helper.show_upgrade_button?(link_action, upgrade_offer) }
 
       it { is_expected.to eq(result) }
     end
@@ -277,7 +357,7 @@
     end
 
     with_them do
-      let(:namespace) { Hashie::Mash.new(trial_active: trial_active) }
+      let(:namespace) { OpenStruct.new(trial_active: trial_active) }
 
       subject { helper.upgrade_button_css_classes(namespace, plan, is_current_plan) }
 
@@ -305,7 +385,7 @@
     end
 
     context 'when namespace is on an active plan' do
-      let(:current_plan) { Hashie::Mash.new(code: 'silver') }
+      let(:current_plan) { OpenStruct.new(code: 'silver') }
 
       it 'returns plans without deprecated' do
         expect(helper.billing_available_plans(plans_data, nil)).to eq([plan])
@@ -313,7 +393,7 @@
     end
 
     context 'when namespace is on a deprecated plan' do
-      let(:current_plan) { Hashie::Mash.new(code: 'bronze') }
+      let(:current_plan) { OpenStruct.new(code: 'bronze') }
 
       it 'returns plans with a deprecated plan' do
         expect(helper.billing_available_plans(plans_data, current_plan)).to eq(plans_data)
@@ -321,7 +401,7 @@
     end
 
     context 'when namespace is on a deprecated plan that has hide_deprecated_card set to true' do
-      let(:current_plan) { Hashie::Mash.new(code: 'bronze') }
+      let(:current_plan) { OpenStruct.new(code: 'bronze') }
       let(:deprecated_plan) { double('Plan', deprecated?: true, code: 'bronze', hide_deprecated_card?: true) }
 
       it 'returns plans without the deprecated plan' do
@@ -330,7 +410,7 @@
     end
 
     context 'when namespace is on a plan that has hide_deprecated_card set to true, but deprecated? is false' do
-      let(:current_plan) { Hashie::Mash.new(code: 'silver') }
+      let(:current_plan) { OpenStruct.new(code: 'silver') }
       let(:plan) { double('Plan', deprecated?: false, code: 'silver', hide_deprecated_card?: true) }
 
       it 'returns plans with the deprecated plan' do
diff --git a/ee/spec/lib/gitlab/subscription_portal/client_spec.rb b/ee/spec/lib/gitlab/subscription_portal/client_spec.rb
index 0fa9edfe09b2..cc3aa7a9ee12 100644
--- a/ee/spec/lib/gitlab/subscription_portal/client_spec.rb
+++ b/ee/spec/lib/gitlab/subscription_portal/client_spec.rb
@@ -137,4 +137,100 @@
       expect(result).to eq({ errors: ["invalid activation code"], success: false })
     end
   end
+
+  describe '#plan_upgrade_offer' do
+    let(:namespace_id) { 111 }
+    let(:headers) do
+      {
+        "Accept" => "application/json",
+        "Content-Type" => "application/json",
+        "X-Admin-Email" => "gl_com_api@gitlab.com",
+        "X-Admin-Token" => "customer_admin_token"
+      }
+    end
+
+    let(:params) do
+      { query: <<~GQL
+        {
+          subscription(namespaceId: "{:namespace_id=>#{namespace_id}}") {
+            eoaStarterBronzeEligible
+            assistedUpgradePlanId
+            freeUpgradePlanId
+          }
+        }
+      GQL
+      }
+    end
+
+    subject(:plan_upgrade_offer) { described_class.plan_upgrade_offer(namespace_id: namespace_id) }
+
+    context 'when the response contains errors' do
+      before do
+        expect(described_class).to receive(:http_post).with('graphql', headers, params).and_return(response)
+      end
+
+      let(:response) do
+        {
+          success: true,
+          data: {
+            'errors' => [{ 'message' => 'this will be ignored' }]
+          }
+        }
+      end
+
+      it 'returns a failure' do
+        expect(plan_upgrade_offer).to eq({ success: false })
+      end
+    end
+
+    context 'when the response does not contain errors' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:eligible, :assisted_plan_id, :free_plan_id) do
+        true | '111111' | '111111'
+        true | '111111' | nil
+        true | nil      | '111111'
+      end
+
+      with_them do
+        before do
+          allow(described_class).to receive(:http_post).and_return({
+              success: true,
+              data: { "data" => { "subscription" => {
+                "eoaStarterBronzeEligible" => eligible,
+                "assistedUpgradePlanId" => assisted_plan_id,
+                "freeUpgradePlanId" => free_plan_id
+                } } }
+          })
+        end
+
+        it 'returns the correct response' do
+          expect(plan_upgrade_offer).to eq({
+            success: true,
+            eligible_for_free_upgrade: eligible,
+            assisted_upgrade_plan_id: assisted_plan_id,
+            free_upgrade_plan_id: free_plan_id
+          })
+        end
+      end
+
+      context 'when subscription is nil' do
+        before do
+          allow(described_class).to receive(:http_post).and_return({
+            success: true,
+            data: { "data" => { "subscription" => nil } }
+          })
+        end
+
+        it 'returns the correct response' do
+          expect(plan_upgrade_offer).to eq({
+            success: true,
+            eligible_for_free_upgrade: nil,
+            assisted_upgrade_plan_id: nil,
+            free_upgrade_plan_id: nil
+          })
+        end
+      end
+    end
+  end
 end
diff --git a/ee/spec/services/fetch_subscription_plans_service_spec.rb b/ee/spec/services/fetch_subscription_plans_service_spec.rb
index 97a8065d93cb..2e0549d62785 100644
--- a/ee/spec/services/fetch_subscription_plans_service_spec.rb
+++ b/ee/spec/services/fetch_subscription_plans_service_spec.rb
@@ -10,7 +10,7 @@
     let(:plan) { 'bronze' }
     let(:response_mock) { double(body: [{ 'foo' => 'bar' }].to_json) }
 
-    context 'when successully fetching plans data' do
+    context 'when successfully fetching plans data' do
       it 'returns parsed JSON' do
         expect(Gitlab::HTTP).to receive(:get)
           .with(
diff --git a/ee/spec/services/gitlab_subscriptions/plan_upgrade_service_spec.rb b/ee/spec/services/gitlab_subscriptions/plan_upgrade_service_spec.rb
new file mode 100644
index 000000000000..0a7d2e1c8f33
--- /dev/null
+++ b/ee/spec/services/gitlab_subscriptions/plan_upgrade_service_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSubscriptions::PlanUpgradeService do
+  subject(:execute) { described_class.new(namespace_id: namespace_id).execute }
+
+  let(:namespace_id) { '111' }
+
+  describe '#execute' do
+    using RSpec::Parameterized::TableSyntax
+
+    before do
+      allow(Gitlab::SubscriptionPortal::Client).to receive(:plan_upgrade_offer).and_return(response)
+    end
+
+    context 'when the response is a failure' do
+      let(:response) { { success: false } }
+
+      it 'returns nil values' do
+        expect(execute).to eq({
+          upgrade_for_free: nil,
+          upgrade_plan_id: nil
+        })
+      end
+    end
+
+    context 'when the response is successful' do
+      where(:eligible, :assisted_id, :free_id, :plan_id) do
+        true  | '111' | '222' | '111'
+        true  | nil   | '222' | '222'
+        true  | '111' | nil   | '111'
+        true  | nil   | nil   | nil
+        false | '111' | '222' | '111'
+        false | '111' | nil   | '111'
+        false | nil   | '222' | '222'
+        nil   | '111' | '222' | nil
+      end
+
+      with_them do
+        let(:response) do
+          {
+            success: true,
+            eligible_for_free_upgrade: eligible,
+            assisted_upgrade_plan_id: assisted_id,
+            free_upgrade_plan_id: free_id
+          }
+        end
+
+        before do
+          expect(Gitlab::SubscriptionPortal::Client).to receive(:plan_upgrade_offer).once.and_return(response)
+        end
+
+        it 'returns the correct values' do
+          expect(execute).to eq({
+            upgrade_for_free: eligible,
+            upgrade_plan_id: plan_id
+          })
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/support/helpers/subscription_portal_helpers.rb b/ee/spec/support/helpers/subscription_portal_helpers.rb
new file mode 100644
index 000000000000..e950f6014408
--- /dev/null
+++ b/ee/spec/support/helpers/subscription_portal_helpers.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module SubscriptionPortalHelpers
+  include StubRequests
+
+  def stub_eoa_eligibility_request(namespace_id)
+    stub_full_request("#{EE::SUBSCRIPTIONS_URL}/graphql", method: :post)
+      .with(
+        body: "{\"query\":\"{\\n  subscription(namespaceId: \\\"#{namespace_id}\\\") {\\n    eoaStarterBronzeEligible\\n    assistedUpgradePlanId\\n    freeUpgradePlanId\\n  }\\n}\\n\"}",
+        headers: {
+          'Accept' => 'application/json',
+          'Content-Type' => 'application/json',
+          'X-Admin-Email' => EE::SUBSCRIPTION_PORTAL_ADMIN_EMAIL,
+          'X-Admin-Token' => EE::SUBSCRIPTION_PORTAL_ADMIN_TOKEN
+        }
+      )
+      .to_return(
+        status: 200,
+        headers: { 'Content-Type' => 'application/json' },
+        body: stubbed_eoa_eligibility_response_body
+      )
+  end
+
+  private
+
+  def stubbed_eoa_eligibility_response_body
+    {
+      "data": {
+        "subscription": {
+          "eoaStarterBronzeEligible": false,
+          "assistedUpgradePlanId": nil,
+          "freeUpgradePlanId": nil
+        }
+      }
+    }.to_json
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 287ac10d08ac..4c05ce63bf76 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -4457,6 +4457,12 @@ msgstr ""
 msgid "BillingPlans|Congratulations, your free trial is activated."
 msgstr ""
 
+msgid "BillingPlans|Current Plan"
+msgstr ""
+
+msgid "BillingPlans|Free upgrade!"
+msgstr ""
+
 msgid "BillingPlans|If you would like to downgrade your plan please contact %{support_link_start}Customer Support%{support_link_end}."
 msgstr ""
 
@@ -4490,6 +4496,9 @@ msgstr ""
 msgid "BillingPlans|billed annually at %{price_per_year}"
 msgstr ""
 
+msgid "BillingPlans|for the remainder of your subscription"
+msgstr ""
+
 msgid "BillingPlans|frequently asked questions"
 msgstr ""
 
@@ -4505,6 +4514,9 @@ msgstr ""
 msgid "BillingPlan|Upgrade"
 msgstr ""
 
+msgid "BillingPlan|Upgrade for free"
+msgstr ""
+
 msgid "Billing|An email address is only visible for users with public emails."
 msgstr ""
 
@@ -8510,9 +8522,6 @@ msgstr ""
 msgid "Current Branch"
 msgstr ""
 
-msgid "Current Plan"
-msgstr ""
-
 msgid "Current Project"
 msgstr ""
 
@@ -30638,6 +30647,9 @@ msgstr ""
 msgid "Updating"
 msgstr ""
 
+msgid "Upgrade offers available!"
+msgstr ""
+
 msgid "Upgrade your plan"
 msgstr ""
 
-- 
GitLab