Commit 5b32ac5c authored by Josianne Hyson's avatar Josianne Hyson

Trigger an email when seat overage occurs

We want to alert group admins to potential additional charges when they
add more users to a group than they have purchased seats for.

Respond to the members added event by checking the usage data of the
namespace and email the user after an overage occurs by calling the
CustomersDot API.

EE: true
Changelog: added
Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/348487
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79807
parent e2c45924
# frozen_string_literal: true
module GitlabSubscriptions
class NotifySeatsExceededService
attr_reader :namespace
def initialize(namespace)
@namespace = namespace
end
def execute
return error('Namespace is not a top level group') if namespace.subgroup?
return error('No subscription found for namespace') if subscription.nil?
subscription.refresh_seat_attributes!
return error('No seat overage') unless subscription.seats_owed > 0
notify_users!
ServiceResponse.success(message: 'Overage notification sent')
end
private
def subscription
namespace.gitlab_subscription
end
def notify_users!
Gitlab::SubscriptionPortal::Client.send_seat_overage_notification(
group: namespace,
max_seats_used: subscription.max_seats_used
)
end
def error(message)
ServiceResponse.error(message: message)
end
end
end
......@@ -12,7 +12,18 @@ module GitlabSubscriptions
worker_has_external_dependencies!
def handle_event(event)
# no-op for now, to be implemented in https://gitlab.com/gitlab-org/gitlab/-/issues/348487
source = case event.data[:source_type]
when 'Group'
Group.find_by_id(event.data[:source_id])
when 'Project'
Project.find_by_id(event.data[:source_id])
else
nil
end
return unless source&.root_ancestor.present?
GitlabSubscriptions::NotifySeatsExceededService.new(source.root_ancestor).execute
end
end
end
......@@ -7,6 +7,14 @@ module Gitlab
extend ActiveSupport::Concern
CONNECTIVITY_ERROR = 'CONNECTIVITY_ERROR'
RESCUABLE_HTTP_ERRORS = [
Gitlab::HTTP::BlockedUrlError,
HTTParty::Error,
Errno::ECONNREFUSED,
Errno::ECONNRESET,
SocketError,
Timeout::Error
].freeze
class_methods do
def activate(activation_code)
......@@ -44,7 +52,7 @@ module Gitlab
else
error(response['errors'])
end
rescue Gitlab::HTTP::BlockedUrlError, HTTParty::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, Timeout::Error => e
rescue *RESCUABLE_HTTP_ERRORS => e
Gitlab::ErrorTracking.log_exception(e)
error(CONNECTIVITY_ERROR)
end
......@@ -183,14 +191,41 @@ module Gitlab
{ query: query, variables: variables }
)
return error(CONNECTIVITY_ERROR) unless response[:success]
parse_errors(response, query_name: 'orderNamespaceNameUpdate').presence || { success: true }
rescue *RESCUABLE_HTTP_ERRORS => e
Gitlab::ErrorTracking.log_exception(e)
error(CONNECTIVITY_ERROR)
end
def send_seat_overage_notification(group:, max_seats_used:)
query = <<~GQL
mutation($namespaceId: Int!, $maxSeatsUsed: Int!, $groupOwners: [GitlabEmailsUserInput!]!) {
sendSeatOverageNotificationEmail(input: {
glNamespaceId: $namespaceId,
maxSeatsUsed: $maxSeatsUsed,
groupOwners: $groupOwners
}) {
errors
}
}
GQL
owners_data = group.owners.map do |owner|
{ id: owner.id, email: owner.notification_email_for(group), fullName: owner.name }
end
errors = response.dig(:data, 'errors') ||
response.dig(:data, 'data', 'orderNamespaceNameUpdate', 'errors')
response = execute_graphql_query(
{
query: query,
variables: { namespaceId: group.id, maxSeatsUsed: max_seats_used, groupOwners: owners_data }
}
)
errors.blank? ? { success: true } : error(errors)
rescue Gitlab::HTTP::BlockedUrlError, HTTParty::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, Timeout::Error => e
parse_errors(response, query_name: 'sendSeatOverageNotificationEmail').presence || { success: true }
rescue *RESCUABLE_HTTP_ERRORS => e
Gitlab::ErrorTracking.log_exception(e)
error(CONNECTIVITY_ERROR)
end
......@@ -218,6 +253,19 @@ module Gitlab
)
end
def parse_errors(response, query_name: nil)
return error(CONNECTIVITY_ERROR) unless response[:success]
errors = [
response.dig(:data, 'errors'),
response.dig(:data, 'data', query_name, 'errors')
]
errors = errors.flat_map(&:presence).compact
error(errors) if errors.any?
end
def error(errors = nil)
{
success: false,
......
......@@ -455,4 +455,114 @@ RSpec.describe Gitlab::SubscriptionPortal::Clients::Graphql do
expect(Gitlab::ErrorTracking).to have_received(:log_exception).with(kind_of(Timeout::Error))
end
end
describe '#send_seat_overage_notification' do
context 'when the subscription portal response is successful' do
it 'returns successfully' do
group = create(:group)
owner_1 = create(:user)
owner_2 = create(:user)
group.add_owner(owner_1)
group.add_owner(owner_2)
expected_query_params = {
variables: {
namespaceId: group.id,
maxSeatsUsed: 10,
groupOwners: [
{ id: owner_1.id, email: owner_1.email, fullName: owner_1.name },
{ id: owner_2.id, email: owner_2.email, fullName: owner_2.name }
]
},
query: <<~GQL
mutation($namespaceId: Int!, $maxSeatsUsed: Int!, $groupOwners: [GitlabEmailsUserInput!]!) {
sendSeatOverageNotificationEmail(input: {
glNamespaceId: $namespaceId,
maxSeatsUsed: $maxSeatsUsed,
groupOwners: $groupOwners
}) {
errors
}
}
GQL
}
portal_response = {
success: true,
data: {
"data" => {
"sendSeatOverageNotificationEmail" => {
"errors" => []
}
}
}
}
expect(client).to receive(:execute_graphql_query).with(expected_query_params).and_return(portal_response)
request = client.send_seat_overage_notification(
group: group,
max_seats_used: 10
)
expect(request).to eq({ success: true })
end
end
context 'when the subscription portal response is unsuccessful' do
it 'returns an error response' do
expected_query_params = {
variables: { namespaceId: 1, maxSeatsUsed: nil, groupOwners: [] },
query: <<~GQL
mutation($namespaceId: Int!, $maxSeatsUsed: Int!, $groupOwners: [GitlabEmailsUserInput!]!) {
sendSeatOverageNotificationEmail(input: {
glNamespaceId: $namespaceId,
maxSeatsUsed: $maxSeatsUsed,
groupOwners: $groupOwners
}) {
errors
}
}
GQL
}
portal_response = {
success: true,
data: {
"errors" => [
{
"message" => "Argument 'maxSeatsUsed' on InputObject 'SendSeatOverageNotificationEmailInput' has an invalid value (null). Expected type 'Int!'.",
"locations" => [{ "line": 2, "column": 43 }],
"path" => %w[mutation sendSeatOverageNotificationEmail input maxSeatsUsed],
"extensions" => {
"code" => "argumentLiteralsIncompatible",
"typeName" => "InputObject",
"argumentName" => "maxSeatsUsed"
}
}
]
}
}
expect(client).to receive(:execute_graphql_query).with(expected_query_params).and_return(portal_response)
request = client.send_seat_overage_notification(group: build(:group, id: 1), max_seats_used: nil)
expect(request[:success]).to be false
expect(request[:errors]).not_to be_empty
end
end
context 'when there is a network connectivity error' do
it 'returns an error response' do
allow(client).to receive(:execute_graphql_query).and_raise(HTTParty::Error)
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(kind_of(HTTParty::Error))
request = client.send_seat_overage_notification(group: build(:group), max_seats_used: nil)
expect(request).to eq({ success: false, errors: "CONNECTIVITY_ERROR" })
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSubscriptions::NotifySeatsExceededService, :saas do
describe '#execute' do
context 'when the supplied group is a subgroup' do
it 'returns the relevant error response' do
group = build(:group, :nested)
expect(described_class.new(group).execute)
.to have_attributes(status: :error, message: 'Namespace is not a top level group')
end
end
context 'when the supplied group does not have a subscription' do
it 'returns the relevant error response' do
group = build(:group)
expect(described_class.new(group).execute)
.to have_attributes(status: :error, message: 'No subscription found for namespace')
end
end
context 'when the group has not exceeded the purchased seats' do
it 'returns the relevant error response' do
group = create(:group)
create(:gitlab_subscription, namespace: group)
expect(described_class.new(group).execute)
.to have_attributes(status: :error, message: 'No seat overage')
end
end
context 'when the top level group has exceeded its purchased seats' do
let_it_be(:group) { create(:group) }
let_it_be(:owner_1) { create(:user) }
let_it_be(:owner_2) { create(:user) }
before do
create(:gitlab_subscription, namespace: group, seats: 1)
group.add_owner(owner_1)
group.add_developer(create(:user))
group.add_owner(owner_2)
end
it 'triggers an email to each group owner and returns successfully' do
expect(Gitlab::SubscriptionPortal::Client)
.to receive(:send_seat_overage_notification)
.with(group: group, max_seats_used: 3)
.and_return({ success: true })
expect(described_class.new(group).execute)
.to have_attributes(status: :success, message: 'Overage notification sent')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSubscriptions::NotifySeatsExceededWorker do
describe '#handle_event' do
let_it_be(:group) { create(:group) }
context 'when the event source is unrecognized' do
it 'does not call the notification service' do
namespace = create(:namespace)
event = Members::MembersAddedEvent.new(data: {
source_id: namespace.id,
source_type: 'Namespace'
})
expect(GitlabSubscriptions::NotifySeatsExceededService).not_to receive(:new)
expect(described_class.new.handle_event(event)).to be_nil
end
end
context 'when the event source is a project' do
it 'calls the service with the root ancestor group' do
project = create(:project, namespace: group)
event = Members::MembersAddedEvent.new(data: {
source_id: project.id,
source_type: 'Project'
})
expect(GitlabSubscriptions::NotifySeatsExceededService)
.to receive(:new)
.with(group)
.and_call_original
described_class.new.handle_event(event)
end
end
context 'when the project cannot be found' do
it 'returns nil without calling the notification service' do
event = Members::MembersAddedEvent.new(data: {
source_id: 0,
source_type: 'Project'
})
expect(GitlabSubscriptions::NotifySeatsExceededService).not_to receive(:new)
expect(described_class.new.handle_event(event)).to be_nil
end
end
context 'when the group cannot be found' do
it 'returns nil without calling the notification service' do
event = Members::MembersAddedEvent.new(data: {
source_id: 0,
source_type: group.class.name
})
expect(GitlabSubscriptions::NotifySeatsExceededService).not_to receive(:new)
expect(described_class.new.handle_event(event)).to be_nil
end
end
context 'when supplied valid group data' do
it 'calls the notification service' do
event = Members::MembersAddedEvent.new(data: {
source_id: group.id,
source_type: group.class.name
})
expect(GitlabSubscriptions::NotifySeatsExceededService)
.to receive(:new)
.with(group)
.and_call_original
described_class.new.handle_event(event)
end
end
context 'when the group is a subgroup' do
it 'calls the notification service with the root ancestor' do
child_group = create(:group, parent: group)
event = Members::MembersAddedEvent.new(data: {
source_id: child_group.id,
source_type: child_group.class.name
})
expect(GitlabSubscriptions::NotifySeatsExceededService)
.to receive(:new)
.with(group)
.and_call_original
described_class.new.handle_event(event)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment