rsb-entitlements 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +73 -0
  4. data/Rakefile +25 -0
  5. data/app/controllers/rsb/entitlements/admin/payment_requests_controller.rb +112 -0
  6. data/app/controllers/rsb/entitlements/admin/plans_controller.rb +91 -0
  7. data/app/controllers/rsb/entitlements/admin/usage_counters_controller.rb +69 -0
  8. data/app/jobs/rsb/entitlements/application_job.rb +8 -0
  9. data/app/jobs/rsb/entitlements/entitlement_expiration_job.rb +15 -0
  10. data/app/jobs/rsb/entitlements/payment_request_expiration_job.rb +31 -0
  11. data/app/models/concerns/rsb/entitlements/entitleable.rb +210 -0
  12. data/app/models/rsb/entitlements/application_record.rb +10 -0
  13. data/app/models/rsb/entitlements/entitlement.rb +68 -0
  14. data/app/models/rsb/entitlements/payment_request.rb +70 -0
  15. data/app/models/rsb/entitlements/plan.rb +83 -0
  16. data/app/models/rsb/entitlements/usage_counter.rb +64 -0
  17. data/app/services/rsb/entitlements/usage_counter_service.rb +94 -0
  18. data/app/views/rsb/entitlements/admin/payment_requests/index.html.erb +98 -0
  19. data/app/views/rsb/entitlements/admin/payment_requests/show.html.erb +137 -0
  20. data/app/views/rsb/entitlements/admin/plans/_form.html.erb +202 -0
  21. data/app/views/rsb/entitlements/admin/plans/edit.html.erb +9 -0
  22. data/app/views/rsb/entitlements/admin/plans/index.html.erb +74 -0
  23. data/app/views/rsb/entitlements/admin/plans/new.html.erb +9 -0
  24. data/app/views/rsb/entitlements/admin/plans/show.html.erb +94 -0
  25. data/app/views/rsb/entitlements/admin/usage_counters/index.html.erb +110 -0
  26. data/app/views/rsb/entitlements/admin/usage_counters/trend.html.erb +57 -0
  27. data/config/locales/admin.en.yml +25 -0
  28. data/db/migrate/20260208200001_create_rsb_entitlements_plans.rb +21 -0
  29. data/db/migrate/20260208200002_create_rsb_entitlements_entitlements.rb +23 -0
  30. data/db/migrate/20260208200003_create_rsb_entitlements_usage_counters.rb +21 -0
  31. data/db/migrate/20260208200004_create_rsb_entitlements_payment_requests.rb +37 -0
  32. data/db/migrate/20260213000001_rework_usage_counters_to_ledger.rb +81 -0
  33. data/lib/generators/rsb/entitlements/install/install_generator.rb +26 -0
  34. data/lib/rsb/entitlements/configuration.rb +19 -0
  35. data/lib/rsb/entitlements/engine.rb +134 -0
  36. data/lib/rsb/entitlements/payment_provider/base.rb +148 -0
  37. data/lib/rsb/entitlements/payment_provider/wire.rb +188 -0
  38. data/lib/rsb/entitlements/period_key_calculator.rb +57 -0
  39. data/lib/rsb/entitlements/provider_definition.rb +43 -0
  40. data/lib/rsb/entitlements/provider_registry.rb +145 -0
  41. data/lib/rsb/entitlements/settings_schema.rb +47 -0
  42. data/lib/rsb/entitlements/test_helper.rb +114 -0
  43. data/lib/rsb/entitlements/version.rb +9 -0
  44. data/lib/rsb/entitlements.rb +39 -0
  45. metadata +116 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 989e890034f790d70eb7f72b08ec9e09c32a78a6c662d7fdfdb8c870820493b2
4
+ data.tar.gz: 3792b156c3b2bedd73206fa73a42f8d5739efebdf000a54869faafa09cf96773
5
+ SHA512:
6
+ metadata.gz: fe68f954e44a3555217aa8ffa7af1c5e4adf99a3b86b5603b7eef8857a980ff025499377f7c0f1399028465249286ad1654bc76bb2ef6d91f6e20ea890659c2c
7
+ data.tar.gz: c607f75ca712375e2a8369dfa291146493a0a8e8205e92c24c976c4910ebd6bdc7bc3cda21b903caad4f019bef36648c7a297a30de58f7de950f590aa1648110
data/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Rails SaaS Builder (RSB)
2
+ Copyright (C) 2026 Aleksandr Marchenko
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Lesser General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Lesser General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Lesser General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # rsb-entitlements
2
+
3
+ Plan-based feature gating, entitlements, and metered usage tracking for Rails SaaS Builder. Drop-in `Entitleable` concern for any model. Extensible provider registry for payment integrations (Stripe, wire transfers, admin grants). Includes usage counter ledger and automated expiration.
4
+
5
+ ## Installation
6
+
7
+ ### As part of Rails SaaS Builder
8
+
9
+ ```ruby
10
+ gem "rails-saas-builder"
11
+ ```
12
+
13
+ ### Standalone
14
+
15
+ ```ruby
16
+ gem "rsb-entitlements"
17
+ ```
18
+
19
+ Then run:
20
+
21
+ ```bash
22
+ bundle install
23
+ rails generate rsb_entitlements:install
24
+ rails db:migrate
25
+ ```
26
+
27
+ ## Key Features
28
+
29
+ - Plan management with feature flags and usage limits
30
+ - `Entitleable` concern: mix into any model for plan-based access
31
+ - Feature checking: `entitled_to?(:feature_name)`
32
+ - Usage metering: `within_limit?(:metric)`, `increment_usage(:metric)`
33
+ - Pluggable payment providers (Stripe, wire transfer, custom)
34
+ - Payment request lifecycle with automatic expiration
35
+ - Usage counter ledger (daily, weekly, monthly, cumulative periods)
36
+ - Automatic entitlement expiration
37
+
38
+ ## Basic Usage
39
+
40
+ ```ruby
41
+ # Add to your model
42
+ class Organization < ApplicationRecord
43
+ include RSB::Entitlements::Entitleable
44
+ end
45
+
46
+ # Check features and usage
47
+ org.entitled_to?(:advanced_analytics) #=> true/false
48
+ org.within_limit?(:api_calls) #=> true/false
49
+ org.increment_usage(:api_calls, 1)
50
+
51
+ # Grant an entitlement
52
+ org.grant_entitlement(plan: plan, provider: :admin)
53
+
54
+ # Request payment
55
+ org.request_payment(plan: premium_plan, provider: :stripe)
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ ```ruby
61
+ RSB::Entitlements.configure do |config|
62
+ config.after_entitlement_changed = ->(entitlement) { ... }
63
+ config.after_usage_limit_reached = ->(counter) { ... }
64
+ end
65
+ ```
66
+
67
+ ## Documentation
68
+
69
+ Part of [Rails SaaS Builder](../README.md). See the main README for the full picture.
70
+
71
+ ## License
72
+
73
+ [LGPL-3.0](../LICENSE)
data/Rakefile ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'fileutils'
5
+ require 'rake/testtask'
6
+
7
+ task :prepare_test_db do
8
+ ENV['RAILS_ENV'] = 'test'
9
+ db = File.expand_path('test/dummy/db/test.sqlite3', __dir__)
10
+ FileUtils.rm_f(db)
11
+ require_relative 'test/dummy/config/environment'
12
+ ActiveRecord::Migration.verbose = false
13
+ ActiveRecord::MigrationContext.new(Rails.application.paths['db/migrate'].to_a).migrate
14
+ schema = File.expand_path('test/dummy/db/schema.rb', __dir__)
15
+ File.open(schema, 'w') { |f| ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, f) }
16
+ end
17
+
18
+ Rake::TestTask.new(:test) do |t|
19
+ t.libs << 'test'
20
+ t.pattern = 'test/**/*_test.rb'
21
+ t.verbose = false
22
+ end
23
+
24
+ task test: :prepare_test_db
25
+ task default: :test
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ module Admin
6
+ class PaymentRequestsController < RSB::Admin::AdminController
7
+ before_action :authorize_payment_requests
8
+ before_action :set_payment_request, only: %i[show approve reject refund]
9
+
10
+ def index
11
+ page = params[:page].to_i
12
+ per_page = 20
13
+ scope = RSB::Entitlements::PaymentRequest.order(created_at: :desc)
14
+
15
+ scope = scope.where(status: params[:status]) if params[:status].present?
16
+ scope = scope.where(provider_key: params[:provider_key]) if params[:provider_key].present?
17
+ scope = scope.where(requestable_type: params[:requestable_type]) if params[:requestable_type].present?
18
+
19
+ @payment_requests = scope.limit(per_page).offset(page * per_page)
20
+ @current_page = page
21
+ @per_page = per_page
22
+ end
23
+
24
+ def show
25
+ definition = RSB::Entitlements.providers.find(@payment_request.provider_key)
26
+ if definition
27
+ provider_instance = definition.provider_class.new(@payment_request)
28
+ @provider_details = provider_instance.admin_details
29
+ @provider_definition = definition
30
+ else
31
+ @provider_details = {}
32
+ @provider_definition = nil
33
+ end
34
+ end
35
+
36
+ def approve
37
+ definition = RSB::Entitlements.providers.find(@payment_request.provider_key)
38
+ return redirect_with_alert('Provider not found') unless definition
39
+
40
+ return redirect_with_alert('Request is not actionable') unless @payment_request.actionable?
41
+
42
+ @payment_request.update!(
43
+ resolved_by: current_admin_user.email,
44
+ resolved_at: Time.current
45
+ )
46
+
47
+ provider_instance = definition.provider_class.new(@payment_request)
48
+ provider_instance.complete!
49
+
50
+ redirect_to "/admin/payment_requests/#{@payment_request.id}",
51
+ notice: 'Payment request approved.'
52
+ end
53
+
54
+ def reject
55
+ definition = RSB::Entitlements.providers.find(@payment_request.provider_key)
56
+ return redirect_with_alert('Provider not found') unless definition
57
+
58
+ return redirect_with_alert('Request is not actionable') unless @payment_request.actionable?
59
+
60
+ @payment_request.update!(
61
+ admin_note: params[:admin_note],
62
+ resolved_by: current_admin_user.email,
63
+ resolved_at: Time.current
64
+ )
65
+
66
+ provider_instance = definition.provider_class.new(@payment_request)
67
+ provider_instance.reject!
68
+
69
+ redirect_to "/admin/payment_requests/#{@payment_request.id}",
70
+ notice: 'Payment request rejected.'
71
+ end
72
+
73
+ def refund
74
+ definition = RSB::Entitlements.providers.find(@payment_request.provider_key)
75
+ return redirect_with_alert('Provider not found') unless definition
76
+ return redirect_with_alert('Provider does not support refunds') unless definition.refundable
77
+ return redirect_with_alert('Request is not approved') unless @payment_request.approved?
78
+
79
+ @payment_request.update!(
80
+ resolved_by: current_admin_user.email,
81
+ resolved_at: Time.current
82
+ )
83
+
84
+ provider_instance = definition.provider_class.new(@payment_request)
85
+ provider_instance.refund!
86
+
87
+ # Revoke linked entitlement if present
88
+ @payment_request.entitlement.revoke!(reason: 'refund') if @payment_request.entitlement&.active?
89
+
90
+ @payment_request.update!(status: 'refunded')
91
+
92
+ redirect_to "/admin/payment_requests/#{@payment_request.id}",
93
+ notice: 'Payment request refunded.'
94
+ end
95
+
96
+ private
97
+
98
+ def set_payment_request
99
+ @payment_request = RSB::Entitlements::PaymentRequest.find(params[:id])
100
+ end
101
+
102
+ def authorize_payment_requests
103
+ authorize_admin_action!(resource: 'payment_requests', action: action_name)
104
+ end
105
+
106
+ def redirect_with_alert(message)
107
+ redirect_to "/admin/payment_requests/#{@payment_request.id}", alert: message
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ module Admin
6
+ class PlansController < RSB::Admin::AdminController
7
+ before_action :authorize_plans
8
+ before_action :set_plan, only: %i[show edit update destroy]
9
+
10
+ def index
11
+ page = params[:page].to_i
12
+ per_page = 20
13
+ @plans = RSB::Entitlements::Plan.order(:created_at)
14
+ .limit(per_page)
15
+ .offset(page * per_page)
16
+ @current_page = page
17
+ @per_page = per_page
18
+ end
19
+
20
+ def show; end
21
+
22
+ def new
23
+ @plan = RSB::Entitlements::Plan.new(
24
+ features: {},
25
+ limits: {},
26
+ metadata: {},
27
+ active: true
28
+ )
29
+ end
30
+
31
+ def create
32
+ @plan = RSB::Entitlements::Plan.new(plan_params)
33
+ if @plan.save
34
+ redirect_to "/admin/plans/#{@plan.id}", notice: 'Plan created.'
35
+ else
36
+ render :new, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def edit; end
41
+
42
+ def update
43
+ if @plan.update(plan_params)
44
+ redirect_to "/admin/plans/#{@plan.id}", notice: 'Plan updated.'
45
+ else
46
+ render :edit, status: :unprocessable_entity
47
+ end
48
+ end
49
+
50
+ def destroy
51
+ if @plan.entitlements.exists?
52
+ redirect_to '/admin/plans',
53
+ alert: 'Cannot delete a plan with active entitlements.'
54
+ else
55
+ @plan.destroy!
56
+ redirect_to '/admin/plans', notice: 'Plan deleted.'
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def set_plan
63
+ @plan = RSB::Entitlements::Plan.find(params[:id])
64
+ end
65
+
66
+ def plan_params
67
+ permitted = params.require(:plan).permit(
68
+ :name, :slug, :interval, :price_cents, :currency, :active,
69
+ features: {},
70
+ limits: {},
71
+ metadata: {}
72
+ )
73
+
74
+ # Convert feature string values to booleans
75
+ if permitted[:features].present?
76
+ permitted[:features] = permitted[:features].transform_values { |v| ['true', true].include?(v) }
77
+ end
78
+
79
+ # Convert limit string values to integers
80
+ permitted[:limits] = permitted[:limits].transform_values(&:to_i) if permitted[:limits].present?
81
+
82
+ permitted
83
+ end
84
+
85
+ def authorize_plans
86
+ authorize_admin_action!(resource: 'plans', action: action_name)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ module Admin
6
+ class UsageCountersController < RSB::Admin::AdminController
7
+ # Displays a filterable, sortable, paginated table of usage counters.
8
+ #
9
+ # Params:
10
+ # metric (optional) - filter by metric name
11
+ # period_key (optional) - filter by period key
12
+ # countable_type (optional) - filter by countable type
13
+ # sort (optional) - sort column: "current_value", "period_key", "created_at"
14
+ # direction (optional) - sort direction: "asc", "desc"
15
+ # page (optional) - pagination page number
16
+ #
17
+ # @return [void]
18
+ def index
19
+ scope = RSB::Entitlements::UsageCounter.all
20
+
21
+ scope = scope.for_metric(params[:metric]) if params[:metric].present?
22
+ scope = scope.for_period(params[:period_key]) if params[:period_key].present?
23
+ scope = scope.where(countable_type: params[:countable_type]) if params[:countable_type].present?
24
+
25
+ sort_col = %w[current_value period_key created_at].include?(params[:sort]) ? params[:sort] : 'created_at'
26
+ sort_dir = params[:direction] == 'asc' ? :asc : :desc
27
+ scope = scope.order(sort_col => sort_dir)
28
+
29
+ @per_page = 25
30
+ @page = (params[:page] || 1).to_i
31
+ @total_count = scope.count
32
+ @usage_counters = scope.offset((@page - 1) * @per_page).limit(@per_page)
33
+
34
+ @available_metrics = RSB::Entitlements::UsageCounter.distinct.pluck(:metric).sort
35
+ @available_types = RSB::Entitlements::UsageCounter.distinct.pluck(:countable_type).sort
36
+ end
37
+
38
+ # Displays a per-metric trend chart using SQL aggregation.
39
+ #
40
+ # Params:
41
+ # metric (required for chart) - the metric to chart
42
+ # countable_type (optional) - filter to specific countable type
43
+ # countable_id (optional) - filter to specific countable
44
+ #
45
+ # @return [void]
46
+ def trend
47
+ @available_metrics = RSB::Entitlements::UsageCounter.distinct.pluck(:metric).sort
48
+ @metric = params[:metric]
49
+
50
+ return unless @metric.present?
51
+
52
+ scope = RSB::Entitlements::UsageCounter.for_metric(@metric)
53
+ scope = scope.where(countable_type: params[:countable_type]) if params[:countable_type].present?
54
+ scope = scope.where(countable_id: params[:countable_id]) if params[:countable_id].present?
55
+
56
+ @trend_data = scope
57
+ .group(:period_key)
58
+ .order(:period_key)
59
+ .sum(:current_value)
60
+ .to_a
61
+ .last(30)
62
+ .to_h
63
+
64
+ @max_value = @trend_data.values.max || 0
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class ApplicationJob < ActiveJob::Base
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class EntitlementExpirationJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform
9
+ Entitlement.active
10
+ .where('expires_at <= ?', Time.current)
11
+ .find_each(&:expire!)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ # Expires stale payment requests whose expires_at has passed.
6
+ # Only affects actionable requests (pending, processing).
7
+ # Sets resolved_by to "system:expiration" and resolved_at to current time.
8
+ #
9
+ # Schedule this job to run periodically (e.g., every hour via cron or recurring job).
10
+ #
11
+ # @example
12
+ # PaymentRequestExpirationJob.perform_later
13
+ class PaymentRequestExpirationJob < ApplicationJob
14
+ queue_as :default
15
+
16
+ def perform
17
+ PaymentRequest
18
+ .actionable
19
+ .where('expires_at <= ?', Time.current)
20
+ .where.not(expires_at: nil)
21
+ .find_each do |request|
22
+ request.update!(
23
+ status: 'expired',
24
+ resolved_by: 'system:expiration',
25
+ resolved_at: Time.current
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ module Entitleable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_many :entitlements,
10
+ class_name: 'RSB::Entitlements::Entitlement',
11
+ as: :entitleable,
12
+ dependent: :restrict_with_error
13
+
14
+ has_many :usage_counters,
15
+ class_name: 'RSB::Entitlements::UsageCounter',
16
+ as: :countable,
17
+ dependent: :destroy
18
+
19
+ has_many :payment_requests,
20
+ class_name: 'RSB::Entitlements::PaymentRequest',
21
+ as: :requestable,
22
+ dependent: :restrict_with_error
23
+ end
24
+
25
+ def entitlement_source
26
+ self
27
+ end
28
+
29
+ def current_entitlement
30
+ entitlement_source.entitlements.active.order(created_at: :desc).first
31
+ end
32
+
33
+ def current_plan
34
+ current_entitlement&.plan
35
+ end
36
+
37
+ def entitled_to?(feature)
38
+ current_plan&.feature?(feature) || false
39
+ end
40
+
41
+ # Checks if the entitleable is within the usage limit for a metric
42
+ # in the current period.
43
+ #
44
+ # @param metric [String, Symbol] the metric name
45
+ # @return [Boolean] true if within limit or unlimited, false if at/over limit or no plan
46
+ def within_limit?(metric)
47
+ plan = current_plan
48
+ return false unless plan
49
+
50
+ config = plan.limit_config_for(metric)
51
+ return true unless config # metric not defined = unlimited
52
+
53
+ limit_value = config['limit']
54
+ return true if limit_value.nil? # nil limit = unlimited
55
+
56
+ period_key = PeriodKeyCalculator.current_key(config['period'])
57
+ counter = current_period_counter(metric, period_key, plan)
58
+ return true unless counter
59
+
60
+ counter.current_value < limit_value
61
+ end
62
+
63
+ # Returns the remaining usage quota for a metric in the current period.
64
+ #
65
+ # @param metric [String, Symbol] the metric name
66
+ # @return [Integer, nil] remaining count, or nil if unlimited or no plan
67
+ def remaining(metric)
68
+ plan = current_plan
69
+ return nil unless plan
70
+
71
+ config = plan.limit_config_for(metric)
72
+ return nil unless config
73
+
74
+ limit_value = config['limit']
75
+ return nil if limit_value.nil?
76
+
77
+ period_key = PeriodKeyCalculator.current_key(config['period'])
78
+ counter = current_period_counter(metric, period_key, plan)
79
+ return limit_value unless counter
80
+
81
+ [limit_value - counter.current_value, 0].max
82
+ end
83
+
84
+ # Grants an entitlement for a plan, revoking the current one if any.
85
+ #
86
+ # @param plan [RSB::Entitlements::Plan] the plan to grant
87
+ # @param provider [String, Symbol] the provider key
88
+ # @param expires_at [Time, nil] optional expiration time
89
+ # @param metadata [Hash] optional metadata
90
+ # @return [RSB::Entitlements::Entitlement] the new entitlement
91
+ def grant_entitlement(plan:, provider:, expires_at: nil, metadata: {})
92
+ source = entitlement_source
93
+ current = source.current_entitlement
94
+ old_plan = current&.plan
95
+ current&.revoke!(reason: 'upgrade')
96
+
97
+ new_entitlement = source.entitlements.create!(
98
+ plan: plan,
99
+ status: 'active',
100
+ provider: provider.to_s,
101
+ activated_at: Time.current,
102
+ expires_at: expires_at,
103
+ metadata: metadata
104
+ )
105
+
106
+ # Handle plan change counter transitions if there was a previous plan
107
+ if old_plan && old_plan.id != plan.id
108
+ UsageCounterService.new.handle_plan_change(source, old_plan: old_plan, new_plan: plan)
109
+ end
110
+
111
+ new_entitlement
112
+ end
113
+
114
+ def revoke_entitlement(reason: 'admin')
115
+ entitlement_source.current_entitlement&.revoke!(reason: reason)
116
+ end
117
+
118
+ # Increments the usage counter for a metric in the current period.
119
+ #
120
+ # Automatically finds or creates the counter record for the current period
121
+ # based on the plan's limit configuration.
122
+ #
123
+ # @param metric [String, Symbol] the metric name
124
+ # @param amount [Integer] the amount to increment (default: 1)
125
+ # @return [Integer] the new current_value
126
+ # @raise [RuntimeError] if no current plan or metric not defined in plan
127
+ def increment_usage(metric, amount = 1)
128
+ source = entitlement_source
129
+ plan = source.current_entitlement&.plan
130
+ raise "No current plan for metric: #{metric}" unless plan
131
+
132
+ config = plan.limit_config_for(metric.to_s)
133
+ raise "No limit defined for metric: #{metric}" unless config
134
+
135
+ period_key = PeriodKeyCalculator.current_key(config['period'])
136
+
137
+ counter = source.usage_counters.find_or_create_by!(
138
+ metric: metric.to_s,
139
+ period_key: period_key,
140
+ plan_id: plan.id
141
+ ) { |c| c.limit = config['limit'] }
142
+
143
+ counter.increment!(amount)
144
+ end
145
+
146
+ # Returns payment requests in actionable states (pending, processing).
147
+ #
148
+ # @return [ActiveRecord::Relation<PaymentRequest>]
149
+ def pending_payment_requests
150
+ payment_requests.where(status: %w[pending processing])
151
+ end
152
+
153
+ # Create a payment request and initiate the provider flow.
154
+ #
155
+ # @param plan [RSB::Entitlements::Plan] the plan to request
156
+ # @param provider [Symbol, String] registered provider key
157
+ # @param amount_cents [Integer, nil] override amount (defaults to plan.price_cents)
158
+ # @param currency [String, nil] override currency (defaults to plan.currency)
159
+ # @param metadata [Hash] arbitrary metadata
160
+ # @return [Hash] provider response ({ instructions: }, { redirect_url: }, or { status: :completed })
161
+ # OR { error: :duplicate_request, existing: PaymentRequest } if duplicate
162
+ # @raise [ArgumentError] if provider is not registered or not enabled
163
+ def request_payment(plan:, provider:, amount_cents: nil, currency: nil, metadata: {})
164
+ provider_key = provider.to_sym
165
+ definition = RSB::Entitlements.providers.find(provider_key)
166
+
167
+ raise ArgumentError, "Provider :#{provider_key} is not registered" unless definition
168
+
169
+ enabled = RSB::Entitlements.providers.enabled.any? { |d| d.key == provider_key }
170
+ raise ArgumentError, "Provider :#{provider_key} is disabled" unless enabled
171
+
172
+ existing = payment_requests.actionable.find_by(plan: plan)
173
+ return { error: :duplicate_request, existing: existing } if existing
174
+
175
+ request = payment_requests.create!(
176
+ plan: plan,
177
+ provider_key: provider_key.to_s,
178
+ amount_cents: amount_cents || plan.price_cents,
179
+ currency: currency || plan.currency,
180
+ metadata: metadata
181
+ )
182
+
183
+ provider_instance = definition.provider_class.new(request)
184
+ provider_instance.initiate!
185
+ end
186
+
187
+ # Returns historical usage counter records for a metric, most recent first.
188
+ #
189
+ # @param metric [String, Symbol] the metric name
190
+ # @param limit [Integer] maximum number of records to return (default: 30)
191
+ # @return [ActiveRecord::Relation<UsageCounter>]
192
+ def usage_history(metric, limit: 30)
193
+ entitlement_source.usage_counters
194
+ .for_metric(metric.to_s)
195
+ .order(period_key: :desc)
196
+ .limit(limit)
197
+ end
198
+
199
+ private
200
+
201
+ def current_period_counter(metric, period_key, plan)
202
+ entitlement_source.usage_counters
203
+ .for_metric(metric.to_s)
204
+ .for_period(period_key)
205
+ .for_plan(plan)
206
+ .last
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ self.table_name_prefix = 'rsb_entitlements_'
8
+ end
9
+ end
10
+ end