From 6e93676ceaf90dc675de8834d80dc38bbd2202ed Mon Sep 17 00:00:00 2001
From: Laura Callahan <lmeckley@gitlab.com>
Date: Tue, 12 Dec 2023 13:42:29 +0000
Subject: [PATCH] Use billing acct query for steplist

Show message when billing acct is present

Unit testing

Move notice to shared component

Use real billingAccount query

Add tests for real billing acct

Test error handling

Put billingAcctQuery behind feature flag

Stub key_contacts flag in feature specs
---
 .../components/checkout/billing_address.vue   | 141 ++++--
 .../components/checkout/billing_address.vue   | 152 ++++---
 .../checkout/sprintf_with_links.vue           |  32 ++
 ...get_billing_account.customer.query.graphql |  41 ++
 ...ubscription_flow_company_paid_plan_spec.rb |   1 +
 ...ubscription_flow_just_me_paid_plan_spec.rb |   1 +
 ..._existing_user_with_eligible_group_spec.rb |   1 +
 ee/spec/frontend/subscriptions/mock_data.js   |  40 ++
 .../checkout/billing_address_spec.js          | 400 +++++++++++++++---
 .../checkout/billing_address_spec.js          | 362 ++++++++++++----
 .../checkout/sprintf_with_links_spec.js       |  69 +++
 locale/gitlab.pot                             |   6 +
 12 files changed, 1020 insertions(+), 226 deletions(-)
 create mode 100644 ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue
 create mode 100644 ee/app/assets/javascripts/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql
 create mode 100644 ee/spec/frontend/vue_shared/purchase_flow/components/checkout/sprintf_with_links_spec.js

diff --git a/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue b/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue
index 08a3dae296372..027809cfbe6b7 100644
--- a/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue
+++ b/ee/app/assets/javascripts/subscriptions/new/components/checkout/billing_address.vue
@@ -13,9 +13,16 @@ import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
 import { s__ } from '~/locale';
 import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
 import Tracking from '~/tracking';
+import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql';
+import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants';
+import { logError } from '~/lib/logger';
+import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { helpPagePath } from '~/helpers/help_page_helper';
 
 export default {
   components: {
+    SprintfWithLinks,
     Step,
     GlFormGroup,
     GlFormInput,
@@ -25,6 +32,23 @@ export default {
     autofocusonshow,
   },
   mixins: [Tracking.mixin()],
+  data() {
+    return {
+      billingAccount: null,
+    };
+  },
+  apollo: {
+    billingAccount: {
+      query: getBillingAccountQuery,
+      client: CUSTOMERSDOT_CLIENT,
+      skip() {
+        return !gon.features?.keyContactsManagement;
+      },
+      error(error) {
+        this.handleError(error);
+      },
+    },
+  },
   computed: {
     ...mapState([
       'country',
@@ -90,15 +114,21 @@ export default {
     isStateValid() {
       return this.isStateRequired ? !isEmpty(this.countryState) : true;
     },
-    isValid() {
+    areRequiredFieldsValid() {
       return (
-        this.isStateValid &&
         !isEmpty(this.country) &&
         !isEmpty(this.streetAddressLine1) &&
         !isEmpty(this.city) &&
         !isEmpty(this.zipCode)
       );
     },
+    isValid() {
+      if (this.shouldShowManageContacts) {
+        return true;
+      }
+
+      return this.isStateValid && this.areRequiredFieldsValid;
+    },
     countryOptionsWithDefault() {
       return [
         {
@@ -117,6 +147,14 @@ export default {
         ...this.stateOptions,
       ];
     },
+    shouldShowManageContacts() {
+      return Boolean(this.billingAccount?.zuoraAccountName);
+    },
+    stepTitle() {
+      return this.shouldShowManageContacts
+        ? this.$options.i18n.contactInformationStepTitle
+        : this.$options.i18n.billingAddressStepTitle;
+    },
   },
   mounted() {
     this.fetchCountries();
@@ -147,15 +185,27 @@ export default {
         property: STEP_BILLING_ADDRESS,
       });
     },
+    handleError(error) {
+      Sentry.captureException(error);
+      logError(error);
+    },
   },
   i18n: {
-    stepTitle: s__('Checkout|Billing address'),
+    billingAddressStepTitle: s__('Checkout|Billing address'),
+    contactInformationStepTitle: s__('Checkout|Contact information'),
     nextStepButtonText: s__('Checkout|Continue to payment'),
     countryLabel: s__('Checkout|Country'),
     streetAddressLabel: s__('Checkout|Street address'),
     cityLabel: s__('Checkout|City'),
     stateLabel: s__('Checkout|State'),
     zipCodeLabel: s__('Checkout|Zip code'),
+    manageContacts: s__(
+      'Checkout|Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.',
+    ),
+  },
+  manageContactsLinkObject: {
+    customersPortalLink: gon.subscriptions_url,
+    manageContactsLink: helpPagePath('subscriptions/customers_portal'),
   },
   stepId: STEP_BILLING_ADDRESS,
 };
@@ -163,56 +213,67 @@ export default {
 <template>
   <step
     :step-id="$options.stepId"
-    :title="$options.i18n.stepTitle"
+    :title="stepTitle"
     :is-valid="isValid"
     :next-step-button-text="$options.i18n.nextStepButtonText"
     @nextStep="trackStepTransition"
     @stepEdit="trackStepEdit"
   >
     <template #body>
-      <gl-form-group :label="$options.i18n.countryLabel" label-size="sm" class="mb-3">
-        <gl-form-select
-          v-model="countryModel"
-          v-autofocusonshow
-          :options="countryOptionsWithDefault"
-          class="js-country"
-          data-testid="country"
-          @change="fetchStates"
-        />
-      </gl-form-group>
-      <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="mb-3">
-        <gl-form-input
-          v-model="streetAddressLine1Model"
-          type="text"
-          data-testid="street-address-1"
+      <div v-if="shouldShowManageContacts" class="gl-mb-3">
+        <sprintf-with-links
+          :message="$options.i18n.manageContacts"
+          :link-object="$options.manageContactsLinkObject"
         />
-        <gl-form-input
-          v-model="streetAddressLine2Model"
-          type="text"
-          data-testid="street-address-2"
-          class="gl-mt-3"
-        />
-      </gl-form-group>
-      <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="mb-3">
-        <gl-form-input v-model="cityModel" type="text" data-testid="city" />
-      </gl-form-group>
-      <div class="combined d-flex">
-        <gl-form-group :label="$options.i18n.stateLabel" label-size="sm" class="mr-3 w-50">
+      </div>
+
+      <div v-else data-testid="checkout-billing-address-form">
+        <gl-form-group :label="$options.i18n.countryLabel" label-size="sm" class="gl-mb-3">
           <gl-form-select
-            v-model="countryStateModel"
-            :options="stateOptionsWithDefault"
-            data-testid="state"
+            v-model="countryModel"
+            v-autofocusonshow
+            :options="countryOptionsWithDefault"
+            class="js-country"
+            data-testid="country"
+            @change="fetchStates"
           />
         </gl-form-group>
-        <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50">
-          <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" />
+        <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="gl-mb-3">
+          <gl-form-input
+            v-model="streetAddressLine1Model"
+            type="text"
+            data-testid="street-address-1"
+          />
+          <gl-form-input
+            v-model="streetAddressLine2Model"
+            type="text"
+            data-testid="street-address-2"
+            class="gl-mt-3"
+          />
         </gl-form-group>
+        <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="gl-mb-3">
+          <gl-form-input v-model="cityModel" type="text" data-testid="city" />
+        </gl-form-group>
+        <div class="combined gl-display-flex">
+          <gl-form-group :label="$options.i18n.stateLabel" label-size="sm" class="mr-3 w-50">
+            <gl-form-select
+              v-model="countryStateModel"
+              :options="stateOptionsWithDefault"
+              data-testid="state"
+            />
+          </gl-form-group>
+          <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50">
+            <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" />
+          </gl-form-group>
+        </div>
       </div>
     </template>
-    <template #summary>
-      <div class="js-summary-line-1">{{ streetAddressLine1 }}</div>
-      <div class="js-summary-line-2">{{ streetAddressLine2 }}</div>
-      <div class="js-summary-line-3">{{ city }}, {{ countryState }} {{ zipCode }}</div>
+    <template v-if="!shouldShowManageContacts" #summary>
+      <div data-testid="checkout-billing-address-summary">
+        <div class="js-summary-line-1">{{ streetAddressLine1 }}</div>
+        <div class="js-summary-line-2">{{ streetAddressLine2 }}</div>
+        <div class="js-summary-line-3">{{ city }}, {{ countryState }} {{ zipCode }}</div>
+      </div>
     </template>
   </step>
 </template>
diff --git a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue
index b51d7b2f16457..baa8baa212dc7 100644
--- a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue
+++ b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/billing_address.vue
@@ -12,9 +12,15 @@ import countriesQuery from 'ee/subscriptions/graphql/queries/countries.query.gra
 import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
 import statesQuery from 'ee/subscriptions/graphql/queries/states.query.graphql';
 import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
+import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue';
 import { s__ } from '~/locale';
 import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
 import { PurchaseEvent } from 'ee/subscriptions/new/constants';
+import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants';
+import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql';
+import { logError } from '~/lib/logger';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { helpPagePath } from '~/helpers/help_page_helper';
 
 export default {
   components: {
@@ -22,6 +28,7 @@ export default {
     GlFormGroup,
     GlFormInput,
     GlFormSelect,
+    SprintfWithLinks,
   },
   directives: {
     autofocusonshow,
@@ -29,9 +36,20 @@ export default {
   data() {
     return {
       countries: [],
+      billingAccount: null,
     };
   },
   apollo: {
+    billingAccount: {
+      client: CUSTOMERSDOT_CLIENT,
+      query: getBillingAccountQuery,
+      skip() {
+        return !gon.features?.keyContactsManagement;
+      },
+      error(error) {
+        this.handleError(error);
+      },
+    },
     customer: {
       query: stateQuery,
     },
@@ -99,21 +117,35 @@ export default {
         this.updateState({ customer: { zipCode } });
       },
     },
+    shouldShowManageContacts() {
+      return Boolean(this.billingAccount?.zuoraAccountName);
+    },
+    stepTitle() {
+      return this.shouldShowManageContacts
+        ? this.$options.i18n.contactInformationStepTitle
+        : this.$options.i18n.billingAddressStepTitle;
+    },
     isStateRequired() {
       return COUNTRIES_WITH_STATES_REQUIRED.includes(this.customer.country);
     },
     isStateValid() {
       return this.isStateRequired ? !isEmpty(this.customer.state) : true;
     },
-    isValid() {
+    areRequiredFieldsValid() {
       return (
-        this.isStateValid &&
         !isEmpty(this.customer.country) &&
         !isEmpty(this.customer.address1) &&
         !isEmpty(this.customer.city) &&
         !isEmpty(this.customer.zipCode)
       );
     },
+    isValid() {
+      if (this.shouldShowManageContacts) {
+        return true;
+      }
+
+      return this.isStateValid && this.areRequiredFieldsValid;
+    },
     countryOptionsWithDefault() {
       return [
         {
@@ -153,15 +185,27 @@ export default {
           this.$emit(PurchaseEvent.ERROR, error);
         });
     },
+    handleError(error) {
+      Sentry.captureException(error);
+      logError(error);
+    },
   },
   i18n: {
-    stepTitle: s__('Checkout|Billing address'),
+    billingAddressStepTitle: s__('Checkout|Billing address'),
+    contactInformationStepTitle: s__('Checkout|Contact information'),
     nextStepButtonText: s__('Checkout|Continue to payment'),
     countryLabel: s__('Checkout|Country'),
     streetAddressLabel: s__('Checkout|Street address'),
     cityLabel: s__('Checkout|City'),
     stateLabel: s__('Checkout|State'),
     zipCodeLabel: s__('Checkout|Zip code'),
+    manageContacts: s__(
+      'Checkout|Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.',
+    ),
+  },
+  manageContactsLinkObject: {
+    customersPortalLink: gon.subscriptions_url,
+    manageContactsLink: helpPagePath('subscriptions/customers_portal'),
   },
   stepId: STEPS[1].id,
 };
@@ -170,68 +214,78 @@ export default {
   <step
     v-if="!$apollo.loading.customer"
     :step-id="$options.stepId"
-    :title="$options.i18n.stepTitle"
+    :title="stepTitle"
     :is-valid="isValid"
     :next-step-button-text="$options.i18n.nextStepButtonText"
   >
     <template #body>
-      <gl-form-group
-        v-if="!$apollo.loading.countries"
-        :label="$options.i18n.countryLabel"
-        label-size="sm"
-        class="mb-3"
-      >
-        <gl-form-select
-          v-model="countryModel"
-          v-autofocusonshow
-          :options="countryOptionsWithDefault"
-          class="js-country"
-          value-field="id"
-          text-field="name"
-          data-testid="country"
-        />
-      </gl-form-group>
-      <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="mb-3">
-        <gl-form-input
-          v-model="streetAddressLine1Model"
-          type="text"
-          data-testid="street-address-1"
-        />
-        <gl-form-input
-          v-model="streetAddressLine2Model"
-          type="text"
-          data-testid="street-address-2"
-          class="gl-mt-3"
+      <div v-if="shouldShowManageContacts" class="gl-mb-3">
+        <sprintf-with-links
+          :message="$options.i18n.manageContacts"
+          :link-object="$options.manageContactsLinkObject"
         />
-      </gl-form-group>
-      <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="mb-3">
-        <gl-form-input v-model="cityModel" type="text" data-testid="city" />
-      </gl-form-group>
-      <div class="combined d-flex">
+      </div>
+      <div v-else data-testid="checkout-billing-address-form">
         <gl-form-group
-          v-if="!$apollo.loading.states && states"
-          :label="$options.i18n.stateLabel"
+          v-if="!$apollo.loading.countries"
+          :label="$options.i18n.countryLabel"
           label-size="sm"
-          class="mr-3 w-50"
+          class="mb-3"
         >
           <gl-form-select
-            v-model="countryStateModel"
-            :options="stateOptionsWithDefault"
+            v-model="countryModel"
+            v-autofocusonshow
+            :options="countryOptionsWithDefault"
+            class="js-country"
             value-field="id"
             text-field="name"
-            data-testid="state"
+            data-testid="country"
+          />
+        </gl-form-group>
+        <gl-form-group :label="$options.i18n.streetAddressLabel" label-size="sm" class="mb-3">
+          <gl-form-input
+            v-model="streetAddressLine1Model"
+            type="text"
+            data-testid="street-address-1"
+          />
+          <gl-form-input
+            v-model="streetAddressLine2Model"
+            type="text"
+            data-testid="street-address-2"
+            class="gl-mt-3"
           />
         </gl-form-group>
-        <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50">
-          <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" />
+        <gl-form-group :label="$options.i18n.cityLabel" label-size="sm" class="mb-3">
+          <gl-form-input v-model="cityModel" type="text" data-testid="city" />
         </gl-form-group>
+        <div class="combined gl-display-flex">
+          <gl-form-group
+            v-if="!$apollo.loading.states && states"
+            :label="$options.i18n.stateLabel"
+            label-size="sm"
+            class="mr-3 w-50"
+          >
+            <gl-form-select
+              v-model="countryStateModel"
+              :options="stateOptionsWithDefault"
+              value-field="id"
+              text-field="name"
+              data-testid="state"
+            />
+          </gl-form-group>
+          <gl-form-group :label="$options.i18n.zipCodeLabel" label-size="sm" class="w-50">
+            <gl-form-input v-model="zipCodeModel" type="text" data-testid="zip-code" />
+          </gl-form-group>
+        </div>
       </div>
     </template>
-    <template #summary>
-      <div class="js-summary-line-1">{{ customer.address1 }}</div>
-      <div class="js-summary-line-2">{{ customer.address2 }}</div>
-      <div class="js-summary-line-3">
-        {{ customer.city }}, {{ customer.country }} {{ selectedStateName }} {{ customer.zipCode }}
+    <template v-if="!shouldShowManageContacts" #summary>
+      <div data-testid="checkout-billing-address-summary">
+        <div class="js-summary-line-1">{{ customer.address1 }}</div>
+        <div class="js-summary-line-2">{{ customer.address2 }}</div>
+        <div class="js-summary-line-3">
+          {{ customer.city }}, {{ customer.country }} {{ selectedStateName }} {{ customer.zipCode }}
+        </div>
       </div>
     </template>
   </step>
diff --git a/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue
new file mode 100644
index 0000000000000..216ec751802e5
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+
+export default {
+  components: {
+    GlLink,
+    GlSprintf,
+  },
+  props: {
+    message: {
+      type: String,
+      required: true,
+    },
+    linkObject: {
+      type: Object,
+      required: true,
+    },
+  },
+};
+</script>
+
+<template>
+  <div>
+    <gl-sprintf :message="message">
+      <template v-for="(href, name) in linkObject" #[name]="{ content }">
+        <gl-link :key="name" class="gl-text-decoration-none!" :href="href" target="_blank">{{
+          content
+        }}</gl-link>
+      </template>
+    </gl-sprintf>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql b/ee/app/assets/javascripts/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql
new file mode 100644
index 0000000000000..06fbe5cbc0b4f
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql
@@ -0,0 +1,41 @@
+query getBillingAccount {
+  billingAccount {
+    zuoraAccountName
+    zuoraAccountVatId
+    vatFieldVisible
+    billingAccountCustomers {
+      id
+      email
+      firstName
+      lastName
+      fullName
+      alternativeContactFullNames
+      alternativeContactFirstNames
+      alternativeContactLastNames
+    }
+    soldToContact {
+      id
+      firstName
+      lastName
+      workEmail
+      address1
+      address2
+      city
+      state
+      postalCode
+      country
+    }
+    billToContact {
+      id
+      firstName
+      lastName
+      workEmail
+      address1
+      address2
+      city
+      state
+      postalCode
+      country
+    }
+  }
+}
diff --git a/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb b/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb
index 00304c6883bbd..62a159584e6b4 100644
--- a/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb
+++ b/ee/spec/features/registrations/saas/subscription_flow_company_paid_plan_spec.rb
@@ -12,6 +12,7 @@
 
   with_them do
     it 'registers the user, processes subscription purchase and creates a group' do
+      stub_feature_flags(key_contacts_management: false)
       sign_up_method.call
 
       expect_to_see_subscription_welcome_form
diff --git a/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb b/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb
index 438c4c26732d5..7299d64a47fb2 100644
--- a/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb
+++ b/ee/spec/features/registrations/saas/subscription_flow_just_me_paid_plan_spec.rb
@@ -12,6 +12,7 @@
 
   with_them do
     it 'registers the user, processes subscription purchase and creates a group' do
+      stub_feature_flags(key_contacts_management: false)
       sign_up_method.call
 
       expect_to_see_subscription_welcome_form
diff --git a/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb b/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb
index 4a3eccc7c0d6c..2a5ab3417c246 100644
--- a/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb
+++ b/ee/spec/features/subscriptions/subscription_flow_for_existing_user_with_eligible_group_spec.rb
@@ -16,6 +16,7 @@
     stub_eligible_namespaces
     stub_billing_plans(nil)
     stub_invoice_preview('null', premium_plan[:id])
+    stub_feature_flags(key_contacts_management: false)
 
     sign_in(user)
   end
diff --git a/ee/spec/frontend/subscriptions/mock_data.js b/ee/spec/frontend/subscriptions/mock_data.js
index c804e6a63f6a0..219a56f731563 100644
--- a/ee/spec/frontend/subscriptions/mock_data.js
+++ b/ee/spec/frontend/subscriptions/mock_data.js
@@ -213,3 +213,43 @@ export const mockInvoicePreviewWithoutPromoOffer = {
     },
   },
 };
+
+export const mockBillingAccount = {
+  zuoraAccountName: 'Day Off LLC',
+  zuoraAccountVatId: 1234,
+  vatFieldVisible: 'true',
+  billingAccountCustomers: {
+    id: 1234,
+    email: 'day@off.com',
+    firstName: 'Ferris',
+    lastName: 'Bueller',
+    fullName: 'Ferris Bueller',
+    alternativeContactFullNames: [],
+    alternativeContactFirstNames: [],
+    alternativeContactLastNames: [],
+  },
+  soldToContact: {
+    id: 5678,
+    firstName: 'Jeanie',
+    lastName: 'Bueller',
+    workEmail: 'jeanie@dayoff.com',
+    address1: '123 Green St',
+    address2: '',
+    city: 'Chicago',
+    state: 'IL',
+    postalCode: 99999,
+    country: 'USA',
+  },
+  billToContact: {
+    id: 5678,
+    firstName: 'Jeanie',
+    lastName: 'Bueller',
+    workEmail: 'jeanie@dayoff.com',
+    address1: '123 Green St',
+    address2: '',
+    city: 'Chicago',
+    state: 'IL',
+    postalCode: 99999,
+    country: 'USA',
+  },
+};
diff --git a/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js b/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js
index 772b6bd5dc80b..38cd611527962 100644
--- a/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js
+++ b/ee/spec/frontend/subscriptions/new/components/checkout/billing_address_spec.js
@@ -1,9 +1,9 @@
-import { mount } from '@vue/test-utils';
 import Vue, { nextTick } from 'vue';
 
 import VueApollo from 'vue-apollo';
 // eslint-disable-next-line no-restricted-imports
 import Vuex from 'vuex';
+import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql';
 import { mockTracking } from 'helpers/tracking_helper';
 import { STEPS } from 'ee/subscriptions/constants';
 import BillingAddress from 'ee/subscriptions/new/components/checkout/billing_address.vue';
@@ -12,9 +12,18 @@ import * as types from 'ee/subscriptions/new/store/mutation_types';
 import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
 import activateNextStepMutation from 'ee/vue_shared/purchase_flow/graphql/mutations/activate_next_step.mutation.graphql';
 import { createMockApolloProvider } from 'ee_jest/vue_shared/purchase_flow/spec_helper';
+import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue';
+import { mockBillingAccount } from 'ee_jest/subscriptions/mock_data';
+import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants';
+import { createMockClient } from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { logError } from '~/lib/logger';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
 
 Vue.use(Vuex);
 Vue.use(VueApollo);
+jest.mock('~/lib/logger');
 
 describe('Billing Address', () => {
   let store;
@@ -41,20 +50,159 @@ describe('Billing Address', () => {
   }
 
   function createComponent(options = {}) {
-    return mount(BillingAddress, {
+    return mountExtended(BillingAddress, {
       ...options,
     });
   }
 
+  // Sets up all required fields
+  const setupValidForm = () => {
+    store.commit(types.UPDATE_COUNTRY, 'country');
+    store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, 'address line 1');
+    store.commit(types.UPDATE_CITY, 'city');
+    store.commit(types.UPDATE_ZIP_CODE, 'zip');
+  };
+
+  // Sets up all fields in the form group
+  const setupAllFormFields = () => {
+    setupValidForm();
+    store.commit(types.UPDATE_STREET_ADDRESS_LINE_TWO, 'address line 2');
+    store.commit(types.UPDATE_COUNTRY_STATE, 'state');
+  };
+
+  const findStep = () => wrapper.findComponent(Step);
+  const findManageContacts = () => wrapper.findComponent(SprintfWithLinks);
+  const findAddressForm = () => wrapper.findByTestId('checkout-billing-address-form');
+  const findAddressSummary = () => wrapper.findByTestId('checkout-billing-address-summary');
+
   beforeEach(() => {
     store = createStore();
     mockApolloProvider = createMockApolloProvider(STEPS);
-    wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+    mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+      [getBillingAccountQuery, jest.fn().mockResolvedValue({ data: { billingAccount: null } })],
+    ]);
   });
 
   describe('mounted', () => {
-    it('should load the countries', () => {
-      expect(actionMocks.fetchCountries).toHaveBeenCalled();
+    describe('when keyContactsManagement flag is true', () => {
+      beforeEach(() => {
+        gon.features = { keyContactsManagement: true };
+      });
+
+      describe.each`
+        billingAccountExists | billingAccountData    | stepTitle                | showAddress
+        ${true}              | ${mockBillingAccount} | ${'Contact information'} | ${false}
+        ${false}             | ${null}               | ${'Billing address'}     | ${true}
+      `(
+        'when billingAccount exists is $billingAccountExists',
+        ({ billingAccountData, stepTitle, showAddress }) => {
+          beforeEach(async () => {
+            mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+              [
+                getBillingAccountQuery,
+                jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+              ],
+            ]);
+
+            wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+            await waitForPromises();
+          });
+
+          it('should load the countries', () => {
+            expect(actionMocks.fetchCountries).toHaveBeenCalled();
+          });
+
+          it('shows step component', () => {
+            expect(findStep().exists()).toBe(true);
+          });
+
+          it('passes correct step title', () => {
+            expect(findStep().props('title')).toEqual(stepTitle);
+          });
+
+          it(`${showAddress ? 'shows' : 'does not show'} address form`, () => {
+            expect(findAddressForm().exists()).toBe(showAddress);
+          });
+
+          it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => {
+            expect(findManageContacts().exists()).toBe(!showAddress);
+          });
+        },
+      );
+    });
+    describe('when keyContactsManagement flag is false', () => {
+      beforeEach(() => {
+        gon.features = { keyContactsManagement: false };
+      });
+
+      describe.each`
+        billingAccountExists | billingAccountData    | stepTitle            | showAddress
+        ${true}              | ${mockBillingAccount} | ${'Billing address'} | ${true}
+        ${false}             | ${null}               | ${'Billing address'} | ${true}
+      `(
+        'when billingAccount exists is $billingAccountExists',
+        ({ billingAccountData, stepTitle, showAddress }) => {
+          beforeEach(async () => {
+            mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+              [
+                getBillingAccountQuery,
+                jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+              ],
+            ]);
+
+            wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+            await waitForPromises();
+          });
+
+          it('should load the countries', () => {
+            expect(actionMocks.fetchCountries).toHaveBeenCalled();
+          });
+
+          it('shows step component', () => {
+            expect(findStep().exists()).toBe(true);
+          });
+
+          it('passes correct step title', () => {
+            expect(findStep().props('title')).toEqual(stepTitle);
+          });
+
+          it(`${showAddress ? 'shows' : 'does not show'} address form`, () => {
+            expect(findAddressForm().exists()).toBe(showAddress);
+          });
+
+          it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => {
+            expect(findManageContacts().exists()).toBe(!showAddress);
+          });
+        },
+      );
+    });
+  });
+
+  describe('manage contacts', () => {
+    beforeEach(async () => {
+      gon.features = { keyContactsManagement: true };
+      mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+        [
+          getBillingAccountQuery,
+          jest.fn().mockResolvedValue({ data: { billingAccount: mockBillingAccount } }),
+        ],
+      ]);
+
+      wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+      await waitForPromises();
+    });
+
+    it('shows correct message', () => {
+      expect(findManageContacts().props('message')).toEqual(
+        'Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.',
+      );
+    });
+
+    it('renders correct number of links', () => {
+      expect(findManageContacts().props('linkObject')).toMatchObject({
+        customersPortalLink: gon.subscriptions_url,
+        manageContactsLink: '/help/subscriptions/customers_portal',
+      });
     });
   });
 
@@ -62,9 +210,14 @@ describe('Billing Address', () => {
     const countrySelect = () => wrapper.find('.js-country');
 
     beforeEach(() => {
+      wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
       store.commit(types.UPDATE_COUNTRY_OPTIONS, [{ text: 'Netherlands', value: 'NL' }]);
     });
 
+    it('render', () => {
+      expect(countrySelect().exists()).toBe(true);
+    });
+
     it('should display the select prompt', () => {
       expect(countrySelect().html()).toContain('<option value="">Select a country</option>');
     });
@@ -83,6 +236,7 @@ describe('Billing Address', () => {
 
   describe('tracking', () => {
     beforeEach(() => {
+      wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
       store.commit(types.UPDATE_COUNTRY, 'US');
       store.commit(types.UPDATE_ZIP_CODE, '10467');
       store.commit(types.UPDATE_COUNTRY_STATE, 'NY');
@@ -123,87 +277,215 @@ describe('Billing Address', () => {
     });
   });
 
-  describe('validations', () => {
-    const isStepValid = () => wrapper.findComponent(Step).props('isValid');
+  describe('when validating', () => {
+    const isStepValid = () => findStep().props('isValid');
 
-    beforeEach(() => {
-      store.commit(types.UPDATE_COUNTRY, 'country');
-      store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, 'address line 1');
-      store.commit(types.UPDATE_CITY, 'city');
-      store.commit(types.UPDATE_ZIP_CODE, 'zip');
-    });
+    describe('with a billing account', () => {
+      beforeEach(async () => {
+        gon.features = { keyContactsManagement: true };
+        mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+          [
+            getBillingAccountQuery,
+            jest.fn().mockResolvedValue({ data: { billingAccount: mockBillingAccount } }),
+          ],
+        ]);
 
-    it('should be valid when country, streetAddressLine1, city and zipCode have been entered', () => {
-      expect(isStepValid()).toBe(true);
-    });
+        wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
 
-    it('should be invalid when country is undefined', async () => {
-      store.commit(types.UPDATE_COUNTRY, null);
-      await nextTick();
+        await waitForPromises();
+        setupValidForm();
+      });
 
-      expect(isStepValid()).toBe(false);
+      it.each`
+        caseName                             | commitFn
+        ${'country is null'}                 | ${() => store.commit(types.UPDATE_COUNTRY, null)}
+        ${'state is null for country that requires state'} | ${() => {
+  store.commit(types.UPDATE_COUNTRY, 'US');
+  store.commit(types.UPDATE_COUNTRY_STATE, null);
+}}
+        ${'when streetAddressLine1 is null'} | ${() => store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, null)}
+        ${'when zipcode is null'}            | ${() => store.commit(types.UPDATE_ZIP_CODE, null)}
+        ${'when city is null'}               | ${() => store.commit(types.UPDATE_CITY, null)}
+      `('passes true isValid prop when $caseName', async ({ commitFn }) => {
+        commitFn();
+        await nextTick();
+
+        expect(isStepValid()).toBe(true);
+      });
     });
 
-    it('should be invalid when state is undefined for countries that require state', async () => {
-      store.commit(types.UPDATE_COUNTRY, 'US');
-      store.commit(types.UPDATE_COUNTRY_STATE, null);
-      await nextTick();
+    describe('without a billing account', () => {
+      beforeEach(() => {
+        wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+        setupValidForm();
+      });
 
-      expect(isStepValid()).toBe(false);
-    });
+      it('should be valid when country, streetAddressLine1, city and zipCode have been entered', () => {
+        expect(isStepValid()).toBe(true);
+      });
 
-    it(`should be valid when state is undefined for countries that don't require state`, async () => {
-      store.commit(types.UPDATE_COUNTRY, 'NZL');
-      store.commit(types.UPDATE_COUNTRY_STATE, null);
-      await nextTick();
+      it('should be invalid when country is undefined', async () => {
+        store.commit(types.UPDATE_COUNTRY, null);
+        await nextTick();
 
-      expect(isStepValid()).toBe(true);
+        expect(isStepValid()).toBe(false);
+      });
+
+      it('should be invalid when state is undefined for countries that require state', async () => {
+        store.commit(types.UPDATE_COUNTRY, 'US');
+        store.commit(types.UPDATE_COUNTRY_STATE, null);
+        await nextTick();
+
+        expect(isStepValid()).toBe(false);
+      });
+
+      it(`should be valid when state is undefined for countries that don't require state`, async () => {
+        store.commit(types.UPDATE_COUNTRY, 'NZL');
+        store.commit(types.UPDATE_COUNTRY_STATE, null);
+        await nextTick();
+
+        expect(isStepValid()).toBe(true);
+      });
+
+      it('should be invalid when streetAddressLine1 is undefined', async () => {
+        store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, null);
+        await nextTick();
+
+        expect(isStepValid()).toBe(false);
+      });
+
+      it('should be invalid when city is undefined', async () => {
+        store.commit(types.UPDATE_CITY, null);
+        await nextTick();
+
+        expect(isStepValid()).toBe(false);
+      });
+
+      it('should be invalid when zipCode is undefined', async () => {
+        store.commit(types.UPDATE_ZIP_CODE, null);
+        await nextTick();
+
+        expect(isStepValid()).toBe(false);
+      });
     });
+  });
 
-    it('should be invalid when streetAddressLine1 is undefined', async () => {
-      store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, null);
-      await nextTick();
+  describe('summary', () => {
+    describe('when keyContactsManagement flag is true', () => {
+      beforeEach(() => {
+        gon.features = { keyContactsManagement: true };
+      });
 
-      expect(isStepValid()).toBe(false);
+      describe.each`
+        billingAccountExists | billingAccountData    | showSummary
+        ${true}              | ${mockBillingAccount} | ${false}
+        ${true}              | ${null}               | ${true}
+        ${false}             | ${null}               | ${true}
+      `(
+        'when billingAccount exists is $billingAccountExists',
+        ({ billingAccountData, showSummary }) => {
+          beforeEach(async () => {
+            mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+              [
+                getBillingAccountQuery,
+                jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+              ],
+            ]);
+
+            wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+
+            await waitForPromises();
+
+            setupAllFormFields();
+            await activateNextStep();
+            await activateNextStep();
+          });
+
+          it(`${showSummary ? 'renders' : 'does not render'}`, () => {
+            expect(findAddressSummary().exists()).toBe(showSummary);
+          });
+        },
+      );
     });
 
-    it('should be invalid when city is undefined', async () => {
-      store.commit(types.UPDATE_CITY, null);
-      await nextTick();
+    describe('when keyContactsManagement flag is false', () => {
+      beforeEach(() => {
+        gon.features = { keyContactsManagement: false };
+      });
 
-      expect(isStepValid()).toBe(false);
+      describe.each`
+        billingAccountExists | billingAccountData
+        ${true}              | ${mockBillingAccount}
+        ${true}              | ${null}
+        ${false}             | ${null}
+        ${false}             | ${mockBillingAccount}
+      `('when billingAccount exists is $billingAccountExists', ({ billingAccountData }) => {
+        beforeEach(async () => {
+          mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+            [
+              getBillingAccountQuery,
+              jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+            ],
+          ]);
+
+          wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+
+          await waitForPromises();
+
+          setupAllFormFields();
+          await activateNextStep();
+          await activateNextStep();
+        });
+
+        it('shows address summary', () => {
+          expect(findAddressSummary().exists()).toBe(true);
+        });
+      });
     });
 
-    it('should be invalid when zipCode is undefined', async () => {
-      store.commit(types.UPDATE_ZIP_CODE, null);
-      await nextTick();
+    describe('without a billing account', () => {
+      beforeEach(async () => {
+        wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+        setupAllFormFields();
+        await activateNextStep();
+        await activateNextStep();
+      });
 
-      expect(isStepValid()).toBe(false);
+      it('should show the entered address line 1', () => {
+        expect(wrapper.find('.js-summary-line-1').text()).toEqual('address line 1');
+      });
+
+      it('should show the entered address line 2', () => {
+        expect(wrapper.find('.js-summary-line-2').text()).toEqual('address line 2');
+      });
+
+      it('should show the entered address city, state and zip code', () => {
+        expect(wrapper.find('.js-summary-line-3').text()).toEqual('city, state zip');
+      });
     });
   });
 
-  describe('showing the summary', () => {
+  describe('when getBillingAccountQuery responds with error', () => {
+    const error = new Error('oh no!');
+
     beforeEach(async () => {
-      store.commit(types.UPDATE_COUNTRY, 'country');
-      store.commit(types.UPDATE_STREET_ADDRESS_LINE_ONE, 'address line 1');
-      store.commit(types.UPDATE_STREET_ADDRESS_LINE_TWO, 'address line 2');
-      store.commit(types.UPDATE_COUNTRY_STATE, 'state');
-      store.commit(types.UPDATE_CITY, 'city');
-      store.commit(types.UPDATE_ZIP_CODE, 'zip');
-      await activateNextStep();
-      await activateNextStep();
-    });
+      gon.features = { keyContactsManagement: true };
+      jest.spyOn(Sentry, 'captureException');
+
+      mockApolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+        [getBillingAccountQuery, jest.fn().mockRejectedValue(error)],
+      ]);
 
-    it('should show the entered address line 1', () => {
-      expect(wrapper.find('.js-summary-line-1').text()).toEqual('address line 1');
+      wrapper = createComponent({ store, apolloProvider: mockApolloProvider });
+      await waitForPromises();
     });
 
-    it('should show the entered address line 2', () => {
-      expect(wrapper.find('.js-summary-line-2').text()).toEqual('address line 2');
+    it('logs to Sentry', () => {
+      expect(Sentry.captureException).toHaveBeenCalledWith(error);
     });
 
-    it('should show the entered address city, state and zip code', () => {
-      expect(wrapper.find('.js-summary-line-3').text()).toEqual('city, state zip');
+    it('logs the error to console', () => {
+      expect(logError).toHaveBeenCalledWith(error);
     });
   });
 });
diff --git a/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js
index bcf608c821a50..426d0f9033659 100644
--- a/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js
+++ b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/billing_address_spec.js
@@ -1,26 +1,42 @@
 import Vue from 'vue';
 import { merge } from 'lodash';
 import VueApollo from 'vue-apollo';
+import getBillingAccountQuery from 'ee/vue_shared/purchase_flow/graphql/queries/get_billing_account.customer.query.graphql';
 import { gitLabResolvers } from 'ee/subscriptions/buy_addons_shared/graphql/resolvers';
 import { STEPS } from 'ee/subscriptions/constants';
 import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
 import BillingAddress from 'ee/vue_shared/purchase_flow/components/checkout/billing_address.vue';
+import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue';
 import Step from 'ee/vue_shared/purchase_flow/components/step.vue';
-import { stateData as initialStateData } from 'ee_jest/subscriptions/mock_data';
+import { mockBillingAccount, stateData as initialStateData } from 'ee_jest/subscriptions/mock_data';
 import { createMockApolloProvider } from 'ee_jest/vue_shared/purchase_flow/spec_helper';
 import waitForPromises from 'helpers/wait_for_promises';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 import { PurchaseEvent } from 'ee/subscriptions/new/constants';
+import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants';
+import { createMockClient } from 'helpers/mock_apollo_helper';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { logError } from '~/lib/logger';
 
 Vue.use(VueApollo);
+jest.mock('~/lib/logger');
 
 describe('Billing Address', () => {
   let wrapper;
   let updateState = jest.fn();
+  let apolloProvider;
 
   const findCountrySelect = () => wrapper.findByTestId('country');
 
-  const createComponent = (apolloLocalState = {}) => {
+  const findStep = () => wrapper.findComponent(Step);
+  const findManageContacts = () => wrapper.findComponent(SprintfWithLinks);
+  const findAddressForm = () => wrapper.findByTestId('checkout-billing-address-form');
+  const findAddressSummary = () => wrapper.findByTestId('checkout-billing-address-summary');
+
+  const createComponent = async (
+    apolloLocalStateData = {},
+    billingAccountFn = jest.fn().mockResolvedValue({ data: { billingAccount: null } }),
+  ) => {
     const apolloResolvers = {
       Query: {
         countries: jest.fn().mockResolvedValue([
@@ -32,27 +48,128 @@ describe('Billing Address', () => {
       Mutation: { updateState },
     };
 
-    const apolloProvider = createMockApolloProvider(STEPS, STEPS[1], {
+    apolloProvider = createMockApolloProvider(STEPS, STEPS[1], {
       ...gitLabResolvers,
       ...apolloResolvers,
     });
     apolloProvider.clients.defaultClient.cache.writeQuery({
       query: stateQuery,
-      data: merge({}, initialStateData, apolloLocalState),
+      data: merge({}, initialStateData, apolloLocalStateData),
     });
+    apolloProvider.clients[CUSTOMERSDOT_CLIENT] = createMockClient([
+      [getBillingAccountQuery, billingAccountFn],
+    ]);
 
     wrapper = mountExtended(BillingAddress, {
       apolloProvider,
     });
+
+    await waitForPromises();
   };
 
+  describe('with keyContactsManagement flag true', () => {
+    beforeEach(() => {
+      gon.features = { keyContactsManagement: true };
+    });
+    describe.each`
+      billingAccountExists | billingAccountData    | stepTitle                | showAddress
+      ${true}              | ${mockBillingAccount} | ${'Contact information'} | ${false}
+      ${false}             | ${null}               | ${'Billing address'}     | ${true}
+    `(
+      'when billingAccount exists is $billingAccountExists',
+      ({ billingAccountData, stepTitle, showAddress }) => {
+        beforeEach(async () => {
+          await createComponent(
+            {},
+            jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+          );
+        });
+
+        it('shows step component', () => {
+          expect(findStep().exists()).toBe(true);
+        });
+
+        it('passes correct step title', () => {
+          expect(findStep().props('title')).toEqual(stepTitle);
+        });
+
+        it(`${showAddress ? 'shows' : 'does not show'} address form`, () => {
+          expect(findAddressForm().exists()).toBe(showAddress);
+        });
+
+        it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => {
+          expect(findManageContacts().exists()).toBe(!showAddress);
+        });
+      },
+    );
+  });
+  describe('with keyContactsManagement flag false', () => {
+    beforeEach(() => {
+      gon.features = { keyContactsManagement: false };
+    });
+
+    describe.each`
+      billingAccountExists | billingAccountData    | stepTitle            | showAddress
+      ${true}              | ${mockBillingAccount} | ${'Billing address'} | ${true}
+      ${false}             | ${null}               | ${'Billing address'} | ${true}
+    `(
+      'when  billingAccount exists is $billingAccountExists',
+      ({ billingAccountData, stepTitle, showAddress }) => {
+        beforeEach(async () => {
+          await createComponent(
+            {},
+            jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+          );
+        });
+
+        it('shows step component', () => {
+          expect(findStep().exists()).toBe(true);
+        });
+
+        it('passes correct step title', () => {
+          expect(findStep().props('title')).toEqual(stepTitle);
+        });
+
+        it(`${showAddress ? 'shows' : 'does not show'} address form`, () => {
+          expect(findAddressForm().exists()).toBe(showAddress);
+        });
+
+        it(`${showAddress ? 'does not show' : 'shows'} manage contact message`, () => {
+          expect(findManageContacts().exists()).toBe(!showAddress);
+        });
+      },
+    );
+  });
+
+  describe('manage contacts', () => {
+    beforeEach(async () => {
+      gon.features = { keyContactsManagement: true };
+
+      await createComponent(
+        {},
+        jest.fn().mockResolvedValue({ data: { billingAccount: mockBillingAccount } }),
+      );
+    });
+
+    it('shows correct message', () => {
+      expect(findManageContacts().props('message')).toEqual(
+        'Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}.',
+      );
+    });
+
+    it('renders correct number of links', () => {
+      expect(findManageContacts().props('linkObject')).toMatchObject({
+        customersPortalLink: gon.subscriptions_url,
+        manageContactsLink: '/help/subscriptions/customers_portal',
+      });
+    });
+  });
+
   describe('country options', () => {
     const countrySelect = () => wrapper.find('.js-country');
 
-    beforeEach(() => {
-      createComponent();
-
-      return waitForPromises();
+    beforeEach(async () => {
+      await createComponent();
     });
 
     it('displays the countries returned from the server', () => {
@@ -71,115 +188,204 @@ describe('Billing Address', () => {
       state: null,
     };
 
-    it('is valid when country, streetAddressLine1, city and zipCode have been entered', async () => {
-      createComponent({ customer: customerData });
-
-      await waitForPromises();
-
-      expect(isStepValid()).toBe(true);
-    });
-
-    it('is invalid when country is undefined', async () => {
-      createComponent({ customer: { ...customerData, country: null } });
-
-      await waitForPromises();
-
-      expect(isStepValid()).toBe(false);
+    describe('with a billing account', () => {
+      it.each`
+        caseName                                           | addressData
+        ${'country is null'}                               | ${{ country: null }}
+        ${'when streetAddressLine1 is null'}               | ${{ address1: null }}
+        ${'when city is null'}                             | ${{ city: null }}
+        ${'when zipcode is null'}                          | ${{ zipCode: null }}
+        ${'state is null for country that requires state'} | ${{ country: 'US' }}
+      `('passes true isValid prop when $caseName', async ({ addressData }) => {
+        await createComponent({ customer: { ...customerData, addressData } });
+
+        expect(isStepValid()).toBe(true);
+      });
     });
 
-    it('is invalid when streetAddressLine1 is undefined', async () => {
-      createComponent({ customer: { ...customerData, address1: null } });
-
-      await waitForPromises();
-
-      expect(isStepValid()).toBe(false);
-    });
+    describe('without a billing account', () => {
+      it('is valid when country, streetAddressLine1, city and zipCode have been entered', async () => {
+        await createComponent({ customer: customerData });
 
-    it('is invalid when city is undefined', async () => {
-      createComponent({ customer: { ...customerData, city: null } });
+        expect(isStepValid()).toBe(true);
+      });
 
-      await waitForPromises();
+      it('is invalid when country is undefined', async () => {
+        await createComponent({ customer: { ...customerData, country: null } });
 
-      expect(isStepValid()).toBe(false);
-    });
+        expect(isStepValid()).toBe(false);
+      });
 
-    it('is invalid when zipCode is undefined', async () => {
-      createComponent({ customer: { ...customerData, zipCode: null } });
+      it('is invalid when streetAddressLine1 is undefined', async () => {
+        await createComponent({ customer: { ...customerData, address1: null } });
 
-      await waitForPromises();
+        expect(isStepValid()).toBe(false);
+      });
 
-      expect(isStepValid()).toBe(false);
-    });
+      it('is invalid when city is undefined', async () => {
+        await createComponent({ customer: { ...customerData, city: null } });
 
-    it('is invalid when state is undefined for countries that require state', async () => {
-      createComponent({ customer: { ...customerData, country: 'US' } });
+        expect(isStepValid()).toBe(false);
+      });
 
-      await waitForPromises();
+      it('is invalid when zipCode is undefined', async () => {
+        await createComponent({ customer: { ...customerData, zipCode: null } });
 
-      expect(isStepValid()).toBe(false);
-    });
+        expect(isStepValid()).toBe(false);
+      });
 
-    it(`is valid when state is undefined for countries that don't require state`, async () => {
-      createComponent({ customer: { ...customerData, country: 'NL' } });
+      it('is invalid when state is undefined for countries that require state', async () => {
+        await createComponent({ customer: { ...customerData, country: 'US' } });
 
-      await waitForPromises();
+        expect(isStepValid()).toBe(false);
+      });
 
-      expect(isStepValid()).toBe(true);
-    });
+      it(`is valid when state is undefined for countries that don't require state`, async () => {
+        await createComponent({ customer: { ...customerData, country: 'NL' } });
 
-    it(`is valid when state exists for countries that require state`, async () => {
-      createComponent({ customer: { ...customerData, country: 'US', state: 'CA' } });
+        expect(isStepValid()).toBe(true);
+      });
 
-      await waitForPromises();
+      it(`is valid when state exists for countries that require state`, async () => {
+        await createComponent({ customer: { ...customerData, country: 'US', state: 'CA' } });
 
-      expect(isStepValid()).toBe(true);
+        expect(isStepValid()).toBe(true);
+      });
     });
   });
 
-  describe('showing the summary', () => {
-    beforeEach(() => {
-      createComponent({
-        customer: {
-          country: 'US',
-          address1: 'address line 1',
-          address2: 'address line 2',
-          city: 'city',
-          zipCode: 'zip',
-          state: 'CA',
+  describe('summary', () => {
+    describe('when keyContactsManagement flag is true', () => {
+      beforeEach(() => {
+        gon.features = { keyContactsManagement: true };
+      });
+      describe.each`
+        billingAccountExists | billingAccountData    | showSummary
+        ${true}              | ${mockBillingAccount} | ${false}
+        ${false}             | ${null}               | ${true}
+      `(
+        'when billingAccount exists is $billingAccountExists',
+        ({ billingAccountData, showSummary }) => {
+          beforeEach(async () => {
+            await createComponent(
+              {
+                customer: {
+                  country: 'US',
+                  address1: 'address line 1',
+                  address2: 'address line 2',
+                  city: 'city',
+                  zipCode: 'zip',
+                  state: 'CA',
+                },
+              },
+              jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+            );
+          });
+
+          it(`${showSummary ? 'renders' : 'does not render'}`, () => {
+            expect(findAddressSummary().exists()).toBe(showSummary);
+          });
         },
+      );
+    });
+    describe('when keyContactsManagement flag is false', () => {
+      beforeEach(() => {
+        gon.features = { keyContactsManagement: false };
       });
-
-      return waitForPromises();
+      describe.each`
+        billingAccountExists | billingAccountData    | showSummary
+        ${true}              | ${mockBillingAccount} | ${true}
+        ${false}             | ${null}               | ${true}
+      `(
+        'when billingAccount exists is $billingAccountExists',
+        ({ billingAccountData, showSummary }) => {
+          beforeEach(async () => {
+            await createComponent(
+              {
+                customer: {
+                  country: 'US',
+                  address1: 'address line 1',
+                  address2: 'address line 2',
+                  city: 'city',
+                  zipCode: 'zip',
+                  state: 'CA',
+                },
+              },
+              jest.fn().mockResolvedValue({ data: { billingAccount: billingAccountData } }),
+            );
+          });
+
+          it(`${showSummary ? 'renders' : 'does not render'}`, () => {
+            expect(findAddressSummary().exists()).toBe(showSummary);
+          });
+        },
+      );
     });
 
-    it('should show the entered address line 1', () => {
-      expect(wrapper.find('.js-summary-line-1').text()).toBe('address line 1');
-    });
+    describe('without billing account', () => {
+      beforeEach(async () => {
+        await createComponent({
+          customer: {
+            country: 'US',
+            address1: 'address line 1',
+            address2: 'address line 2',
+            city: 'city',
+            zipCode: 'zip',
+            state: 'CA',
+          },
+        });
+      });
 
-    it('should show the entered address line 2', () => {
-      expect(wrapper.find('.js-summary-line-2').text()).toBe('address line 2');
-    });
+      it('should show the entered address line 1', () => {
+        expect(wrapper.find('.js-summary-line-1').text()).toBe('address line 1');
+      });
 
-    it('should show the entered address city, state and zip code', () => {
-      expect(wrapper.find('.js-summary-line-3').text()).toBe('city, US California zip');
+      it('should show the entered address line 2', () => {
+        expect(wrapper.find('.js-summary-line-2').text()).toBe('address line 2');
+      });
+
+      it('should show the entered address city, state and zip code', () => {
+        expect(wrapper.find('.js-summary-line-3').text()).toBe('city, US California zip');
+      });
     });
   });
 
   describe('when the mutation fails', () => {
     const error = new Error('Yikes!');
 
-    beforeEach(() => {
+    beforeEach(async () => {
       updateState = jest.fn().mockRejectedValue(error);
-      createComponent({
+      await createComponent({
         customer: { country: 'US' },
       });
+    });
+
+    it('emits an error', async () => {
       findCountrySelect().vm.$emit('input', 'IT');
 
-      return waitForPromises();
-    });
+      await waitForPromises();
 
-    it('emits an error', () => {
       expect(wrapper.emitted(PurchaseEvent.ERROR)).toEqual([[error]]);
     });
   });
+
+  describe('when getBillingAccountQuery responds with error', () => {
+    const error = new Error('oh no!');
+
+    beforeEach(async () => {
+      gon.features = { keyContactsManagement: true };
+      jest.spyOn(Sentry, 'captureException');
+
+      wrapper = await createComponent({}, jest.fn().mockRejectedValue(error));
+      await waitForPromises();
+    });
+
+    it('logs to Sentry', () => {
+      expect(Sentry.captureException).toHaveBeenCalledWith(error);
+    });
+
+    it('logs the error to console', () => {
+      expect(logError).toHaveBeenCalledWith(error);
+    });
+  });
 });
diff --git a/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/sprintf_with_links_spec.js b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/sprintf_with_links_spec.js
new file mode 100644
index 0000000000000..cec9c8dbf3f4d
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/purchase_flow/components/checkout/sprintf_with_links_spec.js
@@ -0,0 +1,69 @@
+import { mount } from '@vue/test-utils';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import SprintfWithLinks from 'ee/vue_shared/purchase_flow/components/checkout/sprintf_with_links.vue';
+
+describe('SprintfWithLinks', () => {
+  let wrapper;
+
+  const createComponent = (propsData = {}) => {
+    return mount(SprintfWithLinks, { propsData });
+  };
+
+  const findSprintf = () => wrapper.findComponent(GlSprintf);
+  const findLinks = () => wrapper.findAllComponents(GlLink);
+
+  const initialLinkObject = { firstLink: 'hitchhikersguide.org', lifeLink: '42.com' };
+
+  describe('with links present in linkObject', () => {
+    beforeEach(() => {
+      wrapper = createComponent({
+        message:
+          'Go to %{firstLinkStart}this link%{firstLinkEnd} for the answer to %{lifeLinkStart}life%{lifeLinkEnd}',
+        linkObject: initialLinkObject,
+      });
+    });
+
+    it('shows correct message', () => {
+      expect(findSprintf().text()).toEqual('Go to');
+    });
+
+    it('renders correct number of links', () => {
+      expect(findLinks()).toHaveLength(2);
+    });
+
+    it('renders correct content in first link', () => {
+      expect(findLinks().at(0).text()).toBe('this link');
+      expect(findLinks().at(0).attributes('href')).toEqual(initialLinkObject.firstLink);
+    });
+
+    it('renders correct content in second link', () => {
+      expect(findLinks().at(1).text()).toBe('life');
+      expect(findLinks().at(1).attributes('href')).toEqual(initialLinkObject.lifeLink);
+    });
+  });
+
+  describe('with links not present in linkObject', () => {
+    beforeEach(() => {
+      wrapper = createComponent({
+        message:
+          '%{towelLinkStart}A towel%{towelLinkEnd}, it says, is about the most massively useful thing an %{firstLinkStart}interstellar hitchhiker%{firstLinkEnd} can have',
+        linkObject: initialLinkObject,
+      });
+    });
+
+    it('shows correct message', () => {
+      expect(findSprintf().text()).toEqual(
+        '%{towelLinkStart}A towel%{towelLinkEnd}, it says, is about the most massively useful thing an',
+      );
+    });
+
+    it('renders correct number of links', () => {
+      expect(findLinks()).toHaveLength(1);
+    });
+
+    it('renders correct content', () => {
+      expect(findLinks().at(0).text()).toBe('interstellar hitchhiker');
+      expect(findLinks().at(0).attributes('href')).toEqual(initialLinkObject.firstLink);
+    });
+  });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7d25727b30207..75132e552ff3f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -10073,6 +10073,9 @@ msgstr ""
 msgid "Checkout|Confirming..."
 msgstr ""
 
+msgid "Checkout|Contact information"
+msgstr ""
+
 msgid "Checkout|Continue to billing"
 msgstr ""
 
@@ -10133,6 +10136,9 @@ msgstr ""
 msgid "Checkout|Invalid coupon code. Enter a valid coupon code."
 msgstr ""
 
+msgid "Checkout|Manage the subscription and billing contacts for your billing account in the %{customersPortalLinkStart}Customers Portal%{customersPortalLinkEnd}. Learn more about %{manageContactsLinkStart}how to manage your contacts%{manageContactsLinkEnd}."
+msgstr ""
+
 msgid "Checkout|Must be %{minimumNumberOfUsers} (your seats in use) or more."
 msgstr ""
 
-- 
GitLab