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.
- checksums.yaml +7 -0
- data/LICENSE +15 -0
- data/README.md +73 -0
- data/Rakefile +25 -0
- data/app/controllers/rsb/entitlements/admin/payment_requests_controller.rb +112 -0
- data/app/controllers/rsb/entitlements/admin/plans_controller.rb +91 -0
- data/app/controllers/rsb/entitlements/admin/usage_counters_controller.rb +69 -0
- data/app/jobs/rsb/entitlements/application_job.rb +8 -0
- data/app/jobs/rsb/entitlements/entitlement_expiration_job.rb +15 -0
- data/app/jobs/rsb/entitlements/payment_request_expiration_job.rb +31 -0
- data/app/models/concerns/rsb/entitlements/entitleable.rb +210 -0
- data/app/models/rsb/entitlements/application_record.rb +10 -0
- data/app/models/rsb/entitlements/entitlement.rb +68 -0
- data/app/models/rsb/entitlements/payment_request.rb +70 -0
- data/app/models/rsb/entitlements/plan.rb +83 -0
- data/app/models/rsb/entitlements/usage_counter.rb +64 -0
- data/app/services/rsb/entitlements/usage_counter_service.rb +94 -0
- data/app/views/rsb/entitlements/admin/payment_requests/index.html.erb +98 -0
- data/app/views/rsb/entitlements/admin/payment_requests/show.html.erb +137 -0
- data/app/views/rsb/entitlements/admin/plans/_form.html.erb +202 -0
- data/app/views/rsb/entitlements/admin/plans/edit.html.erb +9 -0
- data/app/views/rsb/entitlements/admin/plans/index.html.erb +74 -0
- data/app/views/rsb/entitlements/admin/plans/new.html.erb +9 -0
- data/app/views/rsb/entitlements/admin/plans/show.html.erb +94 -0
- data/app/views/rsb/entitlements/admin/usage_counters/index.html.erb +110 -0
- data/app/views/rsb/entitlements/admin/usage_counters/trend.html.erb +57 -0
- data/config/locales/admin.en.yml +25 -0
- data/db/migrate/20260208200001_create_rsb_entitlements_plans.rb +21 -0
- data/db/migrate/20260208200002_create_rsb_entitlements_entitlements.rb +23 -0
- data/db/migrate/20260208200003_create_rsb_entitlements_usage_counters.rb +21 -0
- data/db/migrate/20260208200004_create_rsb_entitlements_payment_requests.rb +37 -0
- data/db/migrate/20260213000001_rework_usage_counters_to_ledger.rb +81 -0
- data/lib/generators/rsb/entitlements/install/install_generator.rb +26 -0
- data/lib/rsb/entitlements/configuration.rb +19 -0
- data/lib/rsb/entitlements/engine.rb +134 -0
- data/lib/rsb/entitlements/payment_provider/base.rb +148 -0
- data/lib/rsb/entitlements/payment_provider/wire.rb +188 -0
- data/lib/rsb/entitlements/period_key_calculator.rb +57 -0
- data/lib/rsb/entitlements/provider_definition.rb +43 -0
- data/lib/rsb/entitlements/provider_registry.rb +145 -0
- data/lib/rsb/entitlements/settings_schema.rb +47 -0
- data/lib/rsb/entitlements/test_helper.rb +114 -0
- data/lib/rsb/entitlements/version.rb +9 -0
- data/lib/rsb/entitlements.rb +39 -0
- 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,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
|