diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb new file mode 100644 index 0000000000000000000000000000000000000000..1cadac7e7d46fb2af9f147f164028045169bd773 --- /dev/null +++ b/app/experiments/require_verification_for_namespace_creation_experiment.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass + def control_behavior + false + end + + def candidate_behavior + true + end + + def candidate? + run + end + + def record_conversion(namespace) + return unless should_track? + + Experiment.by_name(name).record_conversion_event_for_subject(subject, namespace_id: namespace.id) + end + + private + + def subject + context.value[:user] + end +end diff --git a/app/models/experiment.rb b/app/models/experiment.rb index cd0814c476a5620a52ca0f0be2c6c1f7bea9c3fe..2300ec2996d5d5473ce42b90cd3a8669a28bc243 100644 --- a/app/models/experiment.rb +++ b/app/models/experiment.rb @@ -7,7 +7,7 @@ class Experiment < ApplicationRecord validates :name, presence: true, uniqueness: true, length: { maximum: 255 } def self.add_user(name, group_type, user, context = {}) - find_or_create_by!(name: name).record_user_and_group(user, group_type, context) + by_name(name).record_user_and_group(user, group_type, context) end def self.add_group(name, variant:, group:) @@ -15,11 +15,15 @@ def self.add_group(name, variant:, group:) end def self.add_subject(name, variant:, subject:) - find_or_create_by!(name: name).record_subject_and_variant!(subject, variant) + by_name(name).record_subject_and_variant!(subject, variant) end def self.record_conversion_event(name, user, context = {}) - find_or_create_by!(name: name).record_conversion_event_for_user(user, context) + by_name(name).record_conversion_event_for_user(user, context) + end + + def self.by_name(name) + find_or_create_by!(name: name) end # Create or update the recorded experiment_user row for the user in this experiment. @@ -41,6 +45,16 @@ def record_conversion_event_for_user(user, context = {}) experiment_user.update!(converted_at: Time.current, context: merged_context(experiment_user, context)) end + def record_conversion_event_for_subject(subject, context = {}) + raise 'Incompatible subject provided!' unless ExperimentSubject.valid_subject?(subject) + + attr_name = subject.class.table_name.singularize.to_sym + experiment_subject = experiment_subjects.find_by(attr_name => subject) + return unless experiment_subject + + experiment_subject.update!(converted_at: Time.current, context: merged_context(experiment_subject, context)) + end + def record_subject_and_variant!(subject, variant) raise 'Incompatible subject provided!' unless ExperimentSubject.valid_subject?(subject) @@ -57,7 +71,7 @@ def record_subject_and_variant!(subject, variant) private - def merged_context(experiment_user, new_context) - experiment_user.context.deep_merge(new_context.deep_stringify_keys) + def merged_context(experiment_subject, new_context) + experiment_subject.context.deep_merge(new_context.deep_stringify_keys) end end diff --git a/app/models/user.rb b/app/models/user.rb index 3f9f5b3992234d18c212ac870a0a725250a1a7b2..a5acde01116746620405f7ceacf7eb4617227cf3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -331,6 +331,7 @@ def update_tracked_fields!(request) delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true + delegate :requires_credit_card_verification, :requires_credit_card_verification=, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb index 61cf598f178830f16097bc45539b0279766e9708..7190c82bea35af5bd6422dfc9e3c5337ec9fe2b5 100644 --- a/app/services/users/upsert_credit_card_validation_service.rb +++ b/app/services/users/upsert_credit_card_validation_service.rb @@ -2,8 +2,9 @@ module Users class UpsertCreditCardValidationService < BaseService - def initialize(params) + def initialize(params, user) @params = params.to_h.with_indifferent_access + @current_user = user end def execute @@ -18,6 +19,8 @@ def execute ::Users::CreditCardValidation.upsert(@params) + ::Users::UpdateService.new(current_user, user: current_user, requires_credit_card_verification: false).execute! + ServiceResponse.success(message: 'CreditCardValidation was set') rescue ActiveRecord::InvalidForeignKey, ActiveRecord::NotNullViolation => e ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}") diff --git a/config/feature_flags/experiment/require_verification_for_namespace_creation.yml b/config/feature_flags/experiment/require_verification_for_namespace_creation.yml new file mode 100644 index 0000000000000000000000000000000000000000..5772d3217b815561e0f6d65c52b177945042ec54 --- /dev/null +++ b/config/feature_flags/experiment/require_verification_for_namespace_creation.yml @@ -0,0 +1,8 @@ +--- +name: require_verification_for_namespace_creation +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77315 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350251 +milestone: '14.8' +type: experiment +group: group::activation +default_enabled: false diff --git a/db/migrate/20220112115413_add_requires_verification_to_user_details.rb b/db/migrate/20220112115413_add_requires_verification_to_user_details.rb new file mode 100644 index 0000000000000000000000000000000000000000..01fe4f1d5cff6d81d91fdfcd244a1791c3912fe5 --- /dev/null +++ b/db/migrate/20220112115413_add_requires_verification_to_user_details.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddRequiresVerificationToUserDetails < Gitlab::Database::Migration[1.0] + enable_lock_retries! + + def change + add_column :user_details, :requires_credit_card_verification, :boolean, null: false, default: false + end +end diff --git a/db/schema_migrations/20220112115413 b/db/schema_migrations/20220112115413 new file mode 100644 index 0000000000000000000000000000000000000000..9c8c653f69b6dd8c326b78e0e5ebe238ff7c7448 --- /dev/null +++ b/db/schema_migrations/20220112115413 @@ -0,0 +1 @@ +1199adba4c13e9234eabadefeb55ed3cfb19e9d5a87c07b90d438e4f48a973f7 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 0a791ffa1fb6264c4e31f17a089e9e8fd5edb64f..b1e68a41039aff842a100ef76ec877f5c2d19d5a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -20256,6 +20256,7 @@ CREATE TABLE user_details ( pronunciation text, registration_objective smallint, phone text, + requires_credit_card_verification boolean DEFAULT false NOT NULL, CONSTRAINT check_245664af82 CHECK ((char_length(webauthn_xid) <= 100)), CONSTRAINT check_a73b398c60 CHECK ((char_length(phone) <= 50)), CONSTRAINT check_b132136b01 CHECK ((char_length(other_role) <= 100)), diff --git a/ee/app/assets/javascripts/registrations/groups_projects/new/components/credit_card_verification.vue b/ee/app/assets/javascripts/registrations/groups_projects/new/components/credit_card_verification.vue new file mode 100644 index 0000000000000000000000000000000000000000..6a60996f48b77b6ed2d94203bda1cba2df7df6db --- /dev/null +++ b/ee/app/assets/javascripts/registrations/groups_projects/new/components/credit_card_verification.vue @@ -0,0 +1,85 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Zuora from 'ee/billings/components/zuora.vue'; +import { I18N, IFRAME_MINIMUM_HEIGHT } from '../constants'; +import StaticToggle from './static_toggle.vue'; + +export default { + components: { + GlButton, + StaticToggle, + Zuora, + }, + inject: ['completed', 'iframeUrl', 'allowedOrigin'], + data() { + return { + verificationCompleted: this.completed, + }; + }, + watch: { + verificationCompleted() { + this.toggleProjectCreation(); + }, + }, + mounted() { + this.toggleProjectCreation(); + }, + methods: { + submit() { + this.$refs.zuora.submit(); + }, + verified() { + this.verificationCompleted = true; + }, + toggleProjectCreation() { + // Workaround until we refactor group and project creation into Vue + // https://gitlab.com/gitlab-org/gitlab/-/issues/339998 + const el = document.querySelector('.js-toggle-container'); + el.classList.toggle('gl-display-none', !this.verificationCompleted); + }, + }, + i18n: I18N, + iframeHeight: IFRAME_MINIMUM_HEIGHT, +}; +</script> +<template> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full"> + <static-toggle + ref="verifyToggle" + :enabled="!verificationCompleted" + :completed="verificationCompleted" + :title="$options.i18n.verifyToggle" + /> + <div + v-if="!verificationCompleted" + class="gl-border-gray-100 gl-border-solid gl-border-1 gl-rounded-base gl-px-2 gl-py-5 gl-text-left" + > + <div class="gl-px-4 gl-text-secondary gl-font-sm"> + {{ $options.i18n.explanation }} + </div> + <zuora + ref="zuora" + :initial-height="$options.iframeHeight" + :iframe-url="iframeUrl" + :allowed-origin="allowedOrigin" + @success="verified" + /> + <div class="gl-px-4"> + <gl-button + ref="submitButton" + variant="confirm" + type="submit" + class="gl-w-full!" + @click="submit" + > + {{ $options.i18n.submitVerify }} + </gl-button> + </div> + </div> + <static-toggle + ref="createToggle" + :enabled="verificationCompleted" + :title="$options.i18n.createToggle" + /> + </div> +</template> diff --git a/ee/app/assets/javascripts/registrations/groups_projects/new/components/static_toggle.vue b/ee/app/assets/javascripts/registrations/groups_projects/new/components/static_toggle.vue new file mode 100644 index 0000000000000000000000000000000000000000..46a5ea2e9c91e9fe4c83836abf4f62ec92b86c9f --- /dev/null +++ b/ee/app/assets/javascripts/registrations/groups_projects/new/components/static_toggle.vue @@ -0,0 +1,53 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlIcon, + }, + props: { + enabled: { + type: Boolean, + required: true, + }, + completed: { + type: Boolean, + required: false, + default: false, + }, + title: { + type: String, + required: true, + }, + }, + computed: { + icon() { + return this.enabled ? 'chevron-down' : 'chevron-right'; + }, + }, + i18n: { + label: __('Completed'), + }, +}; +</script> +<template> + <div class="gl-border-gray-100 gl-border-l-solid gl-border-1 gl-w-full gl-my-3 gl-pl-3"> + <div class="gl-display-flex gl-align-items-center"> + <span + class="gl-display-flex gl-align-items-center gl-flex-grow-1 gl-font-weight-bold gl-text-blue-500" + :class="{ 'gl-text-gray-400!': !enabled }" + > + <gl-icon :name="icon" :size="24" class="gl-text-gray-500 gl-mr-3" /> + {{ title }} + </span> + <gl-icon + v-if="completed" + name="check-circle-filled" + :size="16" + class="gl-text-green-600" + :aria-label="$options.i18n.label" + /> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/registrations/groups_projects/new/constants.js b/ee/app/assets/javascripts/registrations/groups_projects/new/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..a54bf1c9ec9ef6bc0f59353db88a515749bb8b31 --- /dev/null +++ b/ee/app/assets/javascripts/registrations/groups_projects/new/constants.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const IFRAME_MINIMUM_HEIGHT = 312; +export const I18N = { + verifyToggle: s__('IdentityVerification|Verify your identity'), + createToggle: s__('IdentityVerification|Create a project'), + explanation: s__( + 'IdentityVerification|Before you create your first project, we need you to verify your identity with a valid payment method. You will not be charged during this step. If we ever need to charge you, we will let you know.', + ), + submitVerify: s__('IdentityVerification|Verify your identity'), +}; diff --git a/ee/app/assets/javascripts/registrations/groups_projects/new/index.js b/ee/app/assets/javascripts/registrations/groups_projects/new/index.js index fabc6e54d1bb21521cc03b03440f344138bfeb63..04db199415b2229eac6e1635100176486b23ed5b 100644 --- a/ee/app/assets/javascripts/registrations/groups_projects/new/index.js +++ b/ee/app/assets/javascripts/registrations/groups_projects/new/index.js @@ -1,8 +1,11 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { bindHowToImport } from '~/projects/project_new'; import { displayGroupPath, displayProjectPath } from './path_display'; import showTooltip from './show_tooltip'; +import CreditCardVerification from './components/credit_card_verification.vue'; const importButtonsSubmit = () => { const buttons = document.querySelectorAll('.js-import-project-buttons a'); @@ -33,6 +36,28 @@ const setAutofocus = () => { const mobileTooltipOpts = () => (bp.getBreakpointSize() === 'xs' ? { placement: 'bottom' } : {}); +const mountVerification = () => { + const el = document.querySelector('.js-credit-card-verification'); + + if (!el) { + return null; + } + + const { completed, iframeUrl, allowedOrigin } = el.dataset; + + return new Vue({ + el, + provide: { + completed: parseBoolean(completed), + iframeUrl, + allowedOrigin, + }, + render(createElement) { + return createElement(CreditCardVerification); + }, + }); +}; + export default () => { displayGroupPath('.js-group-path-source', '.js-group-path-display'); displayGroupPath('.js-import-group-path-source', '.js-import-group-path-display'); @@ -41,4 +66,5 @@ export default () => { importButtonsSubmit(); bindHowToImport(); setAutofocus(); + mountVerification(); }; diff --git a/ee/app/controllers/concerns/registrations/verification.rb b/ee/app/controllers/concerns/registrations/verification.rb new file mode 100644 index 0000000000000000000000000000000000000000..3b35bf796de6e6b56efa6baf582f98673b49a7e9 --- /dev/null +++ b/ee/app/controllers/concerns/registrations/verification.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Registrations::Verification + extend ActiveSupport::Concern + + included do + before_action :require_verification, if: :verification_required? + + private + + def verification_required? + html_request? && + request.get? && + current_user&.requires_credit_card_verification + end + + def require_verification + redirect_to new_users_sign_up_groups_project_path + end + + def set_requires_verification + ::Users::UpdateService.new(current_user, user: current_user, requires_credit_card_verification: true).execute! + end + end +end diff --git a/ee/app/controllers/ee/application_controller.rb b/ee/app/controllers/ee/application_controller.rb index 4599e1b0771788fac331a53ecb87831d41715907..f36c0626f9857fa7da8d9f24dab9cb31aa0a113a 100644 --- a/ee/app/controllers/ee/application_controller.rb +++ b/ee/app/controllers/ee/application_controller.rb @@ -6,6 +6,8 @@ module ApplicationController extend ::Gitlab::Utils::Override prepended do + include ::Registrations::Verification + around_action :set_current_ip_address end diff --git a/ee/app/controllers/registrations/groups_projects_controller.rb b/ee/app/controllers/registrations/groups_projects_controller.rb index 964ae3da8d9262e6aad6d60a9303c38aab7012ff..731db163ee2036cad56253958c1f515325431a3b 100644 --- a/ee/app/controllers/registrations/groups_projects_controller.rb +++ b/ee/app/controllers/registrations/groups_projects_controller.rb @@ -6,11 +6,17 @@ class GroupsProjectsController < ApplicationController include Registrations::CreateGroup include OneTrustCSP + skip_before_action :require_verification, only: :new + before_action :set_requires_verification, only: :new, if: -> { helpers.require_verification_experiment.candidate? } + before_action :require_verification, only: [:create, :import], if: -> { current_user.requires_credit_card_verification } + layout 'minimal' feature_category :onboarding def new + helpers.require_verification_experiment.publish_to_database + @group = Group.new(visibility_level: helpers.default_group_visibility) @project = Project.new(namespace: @group) @@ -51,6 +57,7 @@ def create success_url = new_trial_path end + helpers.require_verification_experiment.record_conversion(@group) redirect_to success_url end else @@ -66,6 +73,7 @@ def import @group = Groups::CreateService.new(current_user, modified_group_params).execute if @group.persisted? combined_registration_experiment.track(:create_group, namespace: @group) + helpers.require_verification_experiment.record_conversion(@group) import_url = URI.join(root_url, params[:import_url], "?namespace_id=#{@group.id}").to_s redirect_to import_url diff --git a/ee/app/helpers/ee/registrations_helper.rb b/ee/app/helpers/ee/registrations_helper.rb index f15c1d7848cd92f90bd71ddd392f57f93e9ff22e..3e9c6a4a0e0425ba0bff836f37422908c98846c6 100644 --- a/ee/app/helpers/ee/registrations_helper.rb +++ b/ee/app/helpers/ee/registrations_helper.rb @@ -38,6 +38,20 @@ def registration_verification_data { next_step_url: url } end + def require_verification_experiment + strong_memoize(:require_verification_experiment) do + experiment(:require_verification_for_namespace_creation, user: current_user) + end + end + + def credit_card_verification_data + { + completed: current_user.credit_card_validation.present?.to_s, + iframe_url: ::Gitlab::SubscriptionPortal::REGISTRATION_VALIDATION_FORM_URL, + allowed_origin: ::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL + } + end + private def redirect_path diff --git a/ee/app/views/registrations/groups_projects/new.html.haml b/ee/app/views/registrations/groups_projects/new.html.haml index 7070ff2789ba7b102bd4df4138186efc75237ef3..35f8a62b127d435f0d1b2d8ea7adb6fff29a7089 100644 --- a/ee/app/views/registrations/groups_projects/new.html.haml +++ b/ee/app/views/registrations/groups_projects/new.html.haml @@ -18,7 +18,9 @@ %p.gl-text-center= _('Projects help you organize your work. They contain your file repository, issues, merge requests, and so much more.') - .js-toggle-container.gl-w-full + - if (verify = require_verification_experiment.candidate?) + .js-credit-card-verification{ data: credit_card_verification_data } + .js-toggle-container.gl-w-full{ class: ('gl-display-none' if verify) } %ul.nav.nav-tabs.nav-links.gitlab-tabs.js-group-project-tabs{ role: 'tablist' } %li.nav-item{ role: 'presentation' } %a#blank-project-tab.nav-link.active{ href: '#blank-project-pane', data: { toggle: 'tab', track_label: 'blank_project', track_action: 'click_tab', track_value: '' }, role: 'tab' } diff --git a/ee/spec/controllers/concerns/registrations/verification_spec.rb b/ee/spec/controllers/concerns/registrations/verification_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ad4004ee7bce1a55ae6d95fb13b0abd9393af25 --- /dev/null +++ b/ee/spec/controllers/concerns/registrations/verification_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Registrations::Verification do + controller(ActionController::Base) do + include Registrations::Verification + + before_action :set_requires_verification, only: :new + + def index + head :ok + end + + def create + head :ok + end + + def new + head :ok + end + + def html_request? + request.format.html? + end + end + + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + end + + describe '#require_verification' do + describe 'verification is not required' do + it 'does not redirect' do + get :index + + expect(response).to have_gitlab_http_status(:ok) + end + end + + describe 'verification is required' do + let_it_be(:user) { create(:user, requires_credit_card_verification: true) } + + it 'redirects to the new users sign_up groups_project path' do + get :index + + expect(response).to redirect_to(new_users_sign_up_groups_project_path) + end + + it 'does not redirect on JS requests' do + get :index, format: :js + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'does not redirect on POST requests' do + post :create + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + describe '#set_requires_verification' do + it 'sets the requires_credit_card_verification attribute' do + expect { get :new }.to change { user.reload.requires_credit_card_verification }.to(true) + end + end +end diff --git a/ee/spec/controllers/registrations/groups_projects_controller_spec.rb b/ee/spec/controllers/registrations/groups_projects_controller_spec.rb index 8bf8dbe2b79ea6808b15dab767a540fbecc6feff..ee2b0a6dc17a5eb45eb5cfc41b2b057ab6783992 100644 --- a/ee/spec/controllers/registrations/groups_projects_controller_spec.rb +++ b/ee/spec/controllers/registrations/groups_projects_controller_spec.rb @@ -28,6 +28,28 @@ subject end + + it 'publishes the required verification experiment to the database' do + expect_next_instance_of(RequireVerificationForNamespaceCreationExperiment) do |experiment| + expect(experiment).to receive(:publish_to_database) + end + + subject + end + end + end + + shared_context 'records a conversion event' do + let_it_be(:experiment) { create(:experiment, name: :require_verification_for_namespace_creation) } + let_it_be(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user) } + + before do + stub_experiments(require_verification_for_namespace_creation: true) + end + + it 'records a conversion event for the required verification experiment' do + expect { subject }.to change { experiment_subject.reload.converted_at }.from(nil) + .and change { experiment_subject.context }.to include('namespace_id') end end @@ -55,6 +77,8 @@ it_behaves_like 'hides email confirmation warning' + it_behaves_like 'records a conversion event' + context 'when group and project can be created' do it 'creates a group' do expect { post_create }.to change { Group.count }.by(1) @@ -234,6 +258,8 @@ it_behaves_like 'hides email confirmation warning' + it_behaves_like 'records a conversion event' + context "when a group can't be created" do before do allow_next_instance_of(::Groups::CreateService) do |service| diff --git a/ee/spec/frontend/registrations/groups_projects/new/components/credit_card_verification_spec.js b/ee/spec/frontend/registrations/groups_projects/new/components/credit_card_verification_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ff0b54bada86353f2b01f917182e6df5186f0b3e --- /dev/null +++ b/ee/spec/frontend/registrations/groups_projects/new/components/credit_card_verification_spec.js @@ -0,0 +1,114 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import CreditCardVerification from 'ee/registrations/groups_projects/new/components/credit_card_verification.vue'; +import { IFRAME_MINIMUM_HEIGHT } from 'ee/registrations/groups_projects/new/constants'; +import { setHTMLFixture } from 'helpers/fixtures'; + +describe('CreditCardVerification', () => { + let wrapper; + let zuoraSubmitSpy; + + const IFRAME_URL = 'https://customers.gitlab.com/payment_forms/cc_registration_validation'; + const ALLOWED_ORIGIN = 'https://customers.gitlab.com'; + + const createComponent = (completed = false) => { + wrapper = shallowMount(CreditCardVerification, { + provide: { + completed, + iframeUrl: IFRAME_URL, + allowedOrigin: ALLOWED_ORIGIN, + }, + stubs: { + GlButton, + }, + }); + }; + + const verifyToggleEnabled = () => + wrapper.find({ ref: 'verifyToggle' }).attributes('enabled') === 'true'; + const createToggleEnabled = () => + wrapper.find({ ref: 'createToggle' }).attributes('enabled') === 'true'; + const findZuora = () => wrapper.find({ ref: 'zuora' }); + const findSubmitButton = () => wrapper.find({ ref: 'submitButton' }); + const toggleContainerHidden = () => + document.querySelector('.js-toggle-container').classList.contains('gl-display-none'); + + beforeEach(() => { + setHTMLFixture('<div class="js-toggle-container gl-display-none" />'); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when the component is mounted', () => { + it('enables the right toggles', () => { + expect(verifyToggleEnabled()).toBe(true); + expect(createToggleEnabled()).toBe(false); + }); + + it('hides the toggleContainer', () => { + expect(toggleContainerHidden()).toBe(true); + }); + + it('renders the Zuora component with the right attributes', () => { + expect(findZuora().exists()).toBe(true); + expect(findZuora().attributes()).toMatchObject({ + iframeurl: IFRAME_URL, + allowedorigin: ALLOWED_ORIGIN, + initialheight: IFRAME_MINIMUM_HEIGHT.toString(), + }); + }); + + describe('when verification is completed', () => { + beforeEach(() => { + createComponent(true); + }); + + it('enables the right toggles', () => { + expect(verifyToggleEnabled()).toBe(false); + expect(createToggleEnabled()).toBe(true); + }); + + it('shows the toggleContainer', () => { + expect(toggleContainerHidden()).toBe(false); + }); + + it('hides the Zuora component', () => { + expect(findZuora().exists()).toBe(false); + }); + }); + }); + + describe('when the submit button is clicked', () => { + beforeEach(() => { + zuoraSubmitSpy = jest.fn(); + wrapper.vm.$refs.zuora = { submit: zuoraSubmitSpy }; + findSubmitButton().trigger('click'); + }); + + it('calls the submit method of the Zuora component', () => { + expect(zuoraSubmitSpy).toHaveBeenCalled(); + }); + }); + + describe('when the Zuora component emits a success event', () => { + beforeEach(() => { + findZuora().vm.$emit('success'); + }); + + it('enables the right toggles', () => { + expect(verifyToggleEnabled()).toBe(false); + expect(createToggleEnabled()).toBe(true); + }); + + it('shows the toggleContainer', () => { + expect(toggleContainerHidden()).toBe(false); + }); + + it('hides the Zuora component', () => { + expect(findZuora().exists()).toBe(false); + }); + }); +}); diff --git a/ee/spec/helpers/ee/registrations_helper_spec.rb b/ee/spec/helpers/ee/registrations_helper_spec.rb index ac2ea90bb62627e97a3a8f13c35c79ebc63add25..332dd550a5f8a1d98952ef714a53c611166c9b89 100644 --- a/ee/spec/helpers/ee/registrations_helper_spec.rb +++ b/ee/spec/helpers/ee/registrations_helper_spec.rb @@ -120,4 +120,20 @@ end end end + + describe '#credit_card_verification_data' do + before do + allow(helper).to receive(:current_user).and_return(build(:user)) + end + + it 'returns the expected data' do + expect(helper.credit_card_verification_data).to eq( + { + completed: 'false', + iframe_url: ::Gitlab::SubscriptionPortal::REGISTRATION_VALIDATION_FORM_URL, + allowed_origin: ::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL + } + ) + end + end end diff --git a/lib/api/users.rb b/lib/api/users.rb index efecc7593d046bb4783b47f8030cdf013d5e6fb2..eeb5244466ae3f12d39c548d7bf945e3c96cae21 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -1076,7 +1076,7 @@ def target_user attrs = declared_params(include_missing: false) - service = ::Users::UpsertCreditCardValidationService.new(attrs).execute + service = ::Users::UpsertCreditCardValidationService.new(attrs, user).execute if service.success? present user.credit_card_validation, with: Entities::UserCreditCardValidations diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9300c67805336c2eb9f75faeac9a9d535aa7d4cb..4c34ad7a623a16f153b4d14361244bdd9fbc8da9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -17832,9 +17832,15 @@ msgstr "" msgid "Identities" msgstr "" +msgid "IdentityVerification|Before you create your first project, we need you to verify your identity with a valid payment method. You will not be charged during this step. If we ever need to charge you, we will let you know." +msgstr "" + msgid "IdentityVerification|Before you create your group, we need you to verify your identity with a valid payment method." msgstr "" +msgid "IdentityVerification|Create a project" +msgstr "" + msgid "IdentityVerification|Verify your identity" msgstr "" diff --git a/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb b/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..87417fe1637dd15d158a607a5dac63ba5e9f70c3 --- /dev/null +++ b/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RequireVerificationForNamespaceCreationExperiment, :experiment do + subject(:experiment) { described_class.new(user: user) } + + let_it_be(:user) { create(:user) } + + describe '#candidate?' do + context 'when experiment subject is candidate' do + before do + stub_experiments(require_verification_for_namespace_creation: :candidate) + end + + it 'returns true' do + expect(experiment.candidate?).to eq(true) + end + end + + context 'when experiment subject is control' do + before do + stub_experiments(require_verification_for_namespace_creation: :control) + end + + it 'returns false' do + expect(experiment.candidate?).to eq(false) + end + end + end + + describe '#record_conversion' do + let_it_be(:namespace) { create(:namespace) } + + context 'when should_track? is false' do + before do + allow(experiment).to receive(:should_track?).and_return(false) + end + + it 'does not record a conversion event' do + expect(experiment.publish_to_database).to be_nil + expect(experiment.record_conversion(namespace)).to be_nil + end + end + + context 'when should_track? is true' do + before do + allow(experiment).to receive(:should_track?).and_return(true) + end + + it 'records a conversion event' do + experiment_subject = experiment.publish_to_database + + expect { experiment.record_conversion(namespace) }.to change { experiment_subject.reload.converted_at }.from(nil) + .and change { experiment_subject.context }.to include('namespace_id' => namespace.id) + end + end + end +end diff --git a/spec/models/experiment_spec.rb b/spec/models/experiment_spec.rb index ea5d2b27028b33cb7327ce174a3b84bc11a5aebd..de6ce3ba05318495bbf5777b002c36cb335717c8 100644 --- a/spec/models/experiment_spec.rb +++ b/spec/models/experiment_spec.rb @@ -235,6 +235,54 @@ end end + describe '#record_conversion_event_for_subject' do + let_it_be(:user) { create(:user) } + let_it_be(:experiment) { create(:experiment) } + let_it_be(:context) { { a: 42 } } + + subject(:record_conversion) { experiment.record_conversion_event_for_subject(user, context) } + + context 'when no existing experiment_subject record exists for the given user' do + it 'does not update or create an experiment_subject record' do + expect { record_conversion }.not_to change { ExperimentSubject.all.to_a } + end + end + + context 'when an existing experiment_subject exists for the given user' do + context 'but it has already been converted' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user, converted_at: 2.days.ago) } + + it 'does not update the converted_at value' do + expect { record_conversion }.not_to change { experiment_subject.converted_at } + end + end + + context 'and it has not yet been converted' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user) } + + it 'updates the converted_at value' do + expect { record_conversion }.to change { experiment_subject.reload.converted_at } + end + end + + context 'with no existing context' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user) } + + it 'updates the context' do + expect { record_conversion }.to change { experiment_subject.reload.context }.to('a' => 42) + end + end + + context 'with an existing context' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user, converted_at: 2.days.ago, context: { b: 1 } ) } + + it 'merges the context' do + expect { record_conversion }.to change { experiment_subject.reload.context }.to('a' => 42, 'b' => 1) + end + end + end + end + describe '#record_subject_and_variant!' do let_it_be(:subject_to_record) { create(:group) } let_it_be(:variant) { :control } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ad4873950e19b79641f4d8d81de5530935e84404..a860ccd4f530935d3980c88edf50d978d7495258 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -83,6 +83,9 @@ it { is_expected.to delegate_method(:registration_objective).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:registration_objective=).to(:user_detail).with_arguments(:args).allow_nil } + + it { is_expected.to delegate_method(:requires_credit_card_verification).to(:user_detail).allow_nil } + it { is_expected.to delegate_method(:requires_credit_card_verification=).to(:user_detail).with_arguments(:args).allow_nil } end describe 'associations' do diff --git a/spec/services/users/upsert_credit_card_validation_service_spec.rb b/spec/services/users/upsert_credit_card_validation_service_spec.rb index 952d482f1bdecd0077ab0397be05398bca0e6b67..ac7e619612f532563f05a492af5a9940ab9df386 100644 --- a/spec/services/users/upsert_credit_card_validation_service_spec.rb +++ b/spec/services/users/upsert_credit_card_validation_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Users::UpsertCreditCardValidationService do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, requires_credit_card_verification: true) } let(:user_id) { user.id } let(:credit_card_validated_time) { Time.utc(2020, 1, 1) } @@ -21,7 +21,7 @@ end describe '#execute' do - subject(:service) { described_class.new(params) } + subject(:service) { described_class.new(params, user) } context 'successfully set credit card validation record for the user' do context 'when user does not have credit card validation record' do @@ -42,6 +42,10 @@ expiration_date: Date.new(expiration_year, 1, 31) ) end + + it 'sets the requires_credit_card_verification attribute on the user to false' do + expect { service.execute }.to change { user.reload.requires_credit_card_verification }.to(false) + end end context 'when user has credit card validation record' do