diff --git a/ee/app/models/gitlab_subscriptions/seat_assignment.rb b/ee/app/models/gitlab_subscriptions/seat_assignment.rb
index 03a3b5b8f51798bb0528793b90b41a96216e2549..386813b59640bc325bf9605d0ff46e52fbf61279 100644
--- a/ee/app/models/gitlab_subscriptions/seat_assignment.rb
+++ b/ee/app/models/gitlab_subscriptions/seat_assignment.rb
@@ -6,5 +6,12 @@ class SeatAssignment < ApplicationRecord
     belongs_to :user, optional: false
 
     validates :namespace_id, uniqueness: { scope: :user_id }
+
+    scope :by_namespace, ->(namespace) { where(namespace: namespace) }
+    scope :by_user, ->(user) { where(user: user) }
+
+    def self.find_by_namespace_and_user(namespace, user)
+      by_namespace(namespace).by_user(user).first
+    end
   end
 end
diff --git a/ee/app/services/gitlab_subscriptions/members/activity_service.rb b/ee/app/services/gitlab_subscriptions/members/activity_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d2dcf427dce5c0945674fd3f7035df1994baa0be
--- /dev/null
+++ b/ee/app/services/gitlab_subscriptions/members/activity_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module GitlabSubscriptions
+  module Members
+    class ActivityService
+      include ExclusiveLeaseGuard
+
+      def initialize(user, namespace)
+        @user = user
+        @namespace = namespace&.root_ancestor
+      end
+
+      def execute
+        return ServiceResponse.error(message: 'Invalid params') unless namespace&.group_namespace? && user
+
+        try_obtain_lease do
+          if seat_assignment
+            seat_assignment.update!(last_activity_on: Time.current)
+          else
+            GitlabSubscriptions::SeatAssignment.create!(
+              namespace: namespace, user: user, last_activity_on: Time.current
+            )
+          end
+        end
+
+        ServiceResponse.success(message: 'Member activity tracked')
+      end
+
+      private
+
+      attr_reader :user, :namespace
+
+      def lease_timeout
+        (Time.current.end_of_day - Time.current).to_i
+      end
+
+      # Used by ExclusiveLeaseGuard
+      # do not update the record, if it has been already updated within the last lease_timeout
+      def lease_release?
+        false
+      end
+
+      def lease_key
+        "gitlab_subscriptions:members_activity_event:#{namespace.id}:#{user.id}"
+      end
+
+      def seat_assignment
+        @seat_assignment ||= GitlabSubscriptions::SeatAssignment.find_by_namespace_and_user(namespace, user)
+      end
+    end
+  end
+end
diff --git a/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb b/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb
index 971e79c33c002ba316bd0efd3007f16508020dea..b7c88694f2a3de68171f1961ddfa101ececa3f7d 100644
--- a/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb
+++ b/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb
@@ -2,7 +2,15 @@
 
 FactoryBot.define do
   factory :gitlab_subscription_seat_assignment, class: 'GitlabSubscriptions::SeatAssignment' do
-    namespace
+    namespace { association(:group) }
     user
+
+    trait :active do
+      last_activity_on { 1.day.ago }
+    end
+
+    trait :dormant do
+      last_activity_on { 91.days.ago }
+    end
   end
 end
diff --git a/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb b/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb
index 8bea9f0246bdd9a5044f215cf7b3840462696794..229648db49a11c3b2a96aeaaa6004101427bcf06 100644
--- a/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb
+++ b/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb
@@ -3,6 +3,10 @@
 require 'spec_helper'
 
 RSpec.describe GitlabSubscriptions::SeatAssignment, feature_category: :seat_cost_management do
+  let_it_be(:namespace) { create(:group) }
+  let_it_be(:user) { create(:user) }
+  let_it_be(:extra_dummy_record) { create(:gitlab_subscription_seat_assignment) }
+
   subject { build(:gitlab_subscription_seat_assignment) }
 
   describe 'associations' do
@@ -13,4 +17,30 @@
   describe 'validations' do
     it { is_expected.to validate_uniqueness_of(:namespace_id).scoped_to(:user_id) }
   end
+
+  describe 'scopes' do
+    describe '.by_namespace' do
+      it 'returns records filtered by namespace' do
+        result = create(:gitlab_subscription_seat_assignment, namespace: namespace)
+
+        expect(described_class.by_namespace(namespace)).to match_array(result)
+      end
+    end
+
+    describe '.by_user' do
+      it 'returns records filtered by namespace' do
+        result = create(:gitlab_subscription_seat_assignment, user: user)
+
+        expect(described_class.by_user(user)).to match_array(result)
+      end
+    end
+  end
+
+  describe '.find_by_namespace_and_user' do
+    it 'returns single record by namespace and user' do
+      result = create(:gitlab_subscription_seat_assignment, user: user, namespace: namespace)
+
+      expect(described_class.find_by_namespace_and_user(namespace, user)).to eq(result)
+    end
+  end
 end
diff --git a/ee/spec/services/gitlab_subscriptions/members/activity_service_spec.rb b/ee/spec/services/gitlab_subscriptions/members/activity_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ce4726029e55f1b642860d6b0563eba47c85ce5b
--- /dev/null
+++ b/ee/spec/services/gitlab_subscriptions/members/activity_service_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSubscriptions::Members::ActivityService, :clean_gitlab_redis_shared_state, feature_category: :seat_cost_management do
+  include ExclusiveLeaseHelpers
+
+  let_it_be(:user) { create(:user) }
+  let_it_be(:namespace) { create(:group) }
+
+  let(:lease_key) { "gitlab_subscriptions:members_activity_event:#{namespace.id}:#{user.id}" }
+  let(:instance) { described_class.new(user, namespace) }
+
+  describe '#execute' do
+    subject(:execute) { instance.execute }
+
+    describe 'with valid params', :freeze_time do
+      it 'create a new seat assignment record' do
+        expect do
+          expect(execute).to be_success
+        end.to change {
+          GitlabSubscriptions::SeatAssignment.where(
+            namespace: namespace, user: user, last_activity_on: Time.current
+          ).count
+        }
+      end
+
+      it 'updates the existing seat_assignment record' do
+        seat_assignment = create(:gitlab_subscription_seat_assignment, namespace: namespace, user: user)
+
+        expect do
+          expect(execute).to be_success
+        end.to change { seat_assignment.reload.last_activity_on }
+          .from(nil).to(Time.current)
+      end
+
+      context 'with project belonging to a group' do
+        let(:namespace) { build(:project, :in_group) }
+
+        it 'returns success' do
+          expect do
+            expect(execute).to be_success
+          end.to change { GitlabSubscriptions::SeatAssignment.count }.by(1)
+        end
+      end
+
+      it 'tries to obtain a lease' do
+        ttl = (Time.current.end_of_day - Time.current).to_i
+        expect_to_obtain_exclusive_lease(lease_key, timeout: ttl)
+
+        expect(execute).to be_success
+      end
+
+      context 'when a lease cannot be obtained' do
+        it 'returns success, without updating any record' do
+          stub_exclusive_lease_taken(lease_key)
+
+          expect(instance).not_to receive(:seat_assignment)
+
+          expect(execute).to be_success
+        end
+      end
+    end
+
+    shared_examples 'returns an error' do
+      it do
+        response = execute
+
+        expect(response).to be_error
+        expect(response.message).to eq('Invalid params')
+      end
+    end
+
+    context 'with no namespace' do
+      let(:namespace) { nil }
+
+      it_behaves_like 'returns an error'
+    end
+
+    context 'with namespace not belonging to a group' do
+      let(:namespace) { create(:user_namespace) }
+
+      it_behaves_like 'returns an error'
+    end
+
+    context 'with no user' do
+      let(:user) { nil }
+
+      it_behaves_like 'returns an error'
+    end
+  end
+end