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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRSBEntitlementsUsageCounters < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rsb_entitlements_usage_counters do |t|
|
|
6
|
+
t.references :countable, polymorphic: true, null: false
|
|
7
|
+
t.string :metric, null: false
|
|
8
|
+
t.integer :current_value, null: false, default: 0
|
|
9
|
+
t.integer :limit
|
|
10
|
+
t.string :period
|
|
11
|
+
t.datetime :period_start
|
|
12
|
+
t.datetime :resets_at
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :rsb_entitlements_usage_counters,
|
|
17
|
+
%i[countable_type countable_id metric],
|
|
18
|
+
unique: true,
|
|
19
|
+
name: 'idx_rsb_usage_counters_unique'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRSBEntitlementsPaymentRequests < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rsb_entitlements_payment_requests do |t|
|
|
6
|
+
t.references :requestable, polymorphic: true, null: false
|
|
7
|
+
t.references :plan, null: false, foreign_key: { to_table: :rsb_entitlements_plans }
|
|
8
|
+
t.references :entitlement, null: true, foreign_key: { to_table: :rsb_entitlements_entitlements }
|
|
9
|
+
t.string :provider_key, null: false
|
|
10
|
+
t.string :status, null: false, default: 'pending'
|
|
11
|
+
t.integer :amount_cents, null: false, default: 0
|
|
12
|
+
t.string :currency, null: false, default: 'usd'
|
|
13
|
+
t.string :provider_ref
|
|
14
|
+
t.json :provider_data, default: {}
|
|
15
|
+
t.text :admin_note
|
|
16
|
+
t.string :resolved_by
|
|
17
|
+
t.datetime :resolved_at
|
|
18
|
+
t.datetime :expires_at
|
|
19
|
+
t.json :metadata, default: {}
|
|
20
|
+
t.timestamps
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
add_index :rsb_entitlements_payment_requests,
|
|
24
|
+
%i[requestable_type requestable_id],
|
|
25
|
+
name: 'idx_payment_requests_on_requestable'
|
|
26
|
+
add_index :rsb_entitlements_payment_requests, :status
|
|
27
|
+
add_index :rsb_entitlements_payment_requests, :provider_key
|
|
28
|
+
add_index :rsb_entitlements_payment_requests, :expires_at,
|
|
29
|
+
where: "status IN ('pending', 'processing')",
|
|
30
|
+
name: 'idx_payment_requests_on_expires_at'
|
|
31
|
+
add_index :rsb_entitlements_payment_requests,
|
|
32
|
+
%i[requestable_type requestable_id plan_id],
|
|
33
|
+
unique: true,
|
|
34
|
+
where: "status IN ('pending', 'processing')",
|
|
35
|
+
name: 'idx_payment_requests_actionable_unique'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ReworkUsageCountersToLedger < ActiveRecord::Migration[8.0]
|
|
4
|
+
def up
|
|
5
|
+
# 1. Add new columns
|
|
6
|
+
add_column :rsb_entitlements_usage_counters, :period_key, :string
|
|
7
|
+
add_reference :rsb_entitlements_usage_counters, :plan,
|
|
8
|
+
null: true,
|
|
9
|
+
foreign_key: { to_table: :rsb_entitlements_plans }
|
|
10
|
+
|
|
11
|
+
# 2. Data migration — set defaults for existing records
|
|
12
|
+
# All existing counters become cumulative records.
|
|
13
|
+
# Plan is set from the countable's most recent entitlement.
|
|
14
|
+
execute <<~SQL
|
|
15
|
+
UPDATE rsb_entitlements_usage_counters
|
|
16
|
+
SET period_key = '__cumulative__',
|
|
17
|
+
plan_id = (
|
|
18
|
+
SELECT e.plan_id
|
|
19
|
+
FROM rsb_entitlements_entitlements e
|
|
20
|
+
WHERE e.entitleable_type = rsb_entitlements_usage_counters.countable_type
|
|
21
|
+
AND e.entitleable_id = rsb_entitlements_usage_counters.countable_id
|
|
22
|
+
ORDER BY e.created_at DESC
|
|
23
|
+
LIMIT 1
|
|
24
|
+
)
|
|
25
|
+
SQL
|
|
26
|
+
|
|
27
|
+
# 3. For any counters that still have NULL plan_id (no entitlement found),
|
|
28
|
+
# assign the first plan as a fallback
|
|
29
|
+
fallback_plan_id = RSB::Entitlements::Plan.first&.id
|
|
30
|
+
if fallback_plan_id
|
|
31
|
+
execute <<~SQL
|
|
32
|
+
UPDATE rsb_entitlements_usage_counters
|
|
33
|
+
SET plan_id = #{fallback_plan_id}
|
|
34
|
+
WHERE plan_id IS NULL
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 4. Data migration — convert Plan.limits from flat to nested format
|
|
39
|
+
RSB::Entitlements::Plan.find_each do |plan|
|
|
40
|
+
next if plan.limits.blank?
|
|
41
|
+
|
|
42
|
+
# Skip if already in nested format (first value is a Hash)
|
|
43
|
+
first_value = plan.limits.values.first
|
|
44
|
+
next if first_value.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
nested = plan.limits.transform_values do |limit_value|
|
|
47
|
+
{ 'limit' => limit_value, 'period' => nil }
|
|
48
|
+
end
|
|
49
|
+
plan.update_column(:limits, nested)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 5. Make columns NOT NULL now that data is migrated
|
|
53
|
+
change_column_null :rsb_entitlements_usage_counters, :period_key, false
|
|
54
|
+
change_column_null :rsb_entitlements_usage_counters, :plan_id, false
|
|
55
|
+
|
|
56
|
+
# 6. Drop old columns
|
|
57
|
+
remove_column :rsb_entitlements_usage_counters, :period, :string
|
|
58
|
+
remove_column :rsb_entitlements_usage_counters, :period_start, :datetime
|
|
59
|
+
remove_column :rsb_entitlements_usage_counters, :resets_at, :datetime
|
|
60
|
+
|
|
61
|
+
# 7. Drop old unique index and create new one
|
|
62
|
+
remove_index :rsb_entitlements_usage_counters,
|
|
63
|
+
name: 'idx_rsb_usage_counters_unique'
|
|
64
|
+
|
|
65
|
+
add_index :rsb_entitlements_usage_counters,
|
|
66
|
+
%i[countable_type countable_id metric period_key plan_id],
|
|
67
|
+
unique: true,
|
|
68
|
+
name: 'idx_rsb_usage_counters_unique'
|
|
69
|
+
|
|
70
|
+
# 8. Add supporting indexes
|
|
71
|
+
add_index :rsb_entitlements_usage_counters, :metric,
|
|
72
|
+
name: 'idx_rsb_usage_counters_on_metric'
|
|
73
|
+
add_index :rsb_entitlements_usage_counters, :period_key,
|
|
74
|
+
name: 'idx_rsb_usage_counters_on_period_key'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def down
|
|
78
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
79
|
+
'Cannot reverse usage counter ledger migration (data migration is lossy)'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
namespace 'rsb:entitlements:install'
|
|
7
|
+
source_root File.expand_path('templates', __dir__)
|
|
8
|
+
|
|
9
|
+
desc 'Install rsb-entitlements: copy migrations.'
|
|
10
|
+
|
|
11
|
+
def copy_migrations
|
|
12
|
+
rake 'rsb_entitlements:install:migrations'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def print_post_install
|
|
16
|
+
say ''
|
|
17
|
+
say 'rsb-entitlements installed successfully!', :green
|
|
18
|
+
say ''
|
|
19
|
+
say 'Next steps:'
|
|
20
|
+
say ' 1. rails db:migrate'
|
|
21
|
+
say ' 2. Include RSB::Entitlements::Entitleable in any model that needs plan-based features.'
|
|
22
|
+
say ''
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :after_entitlement_changed,
|
|
7
|
+
:after_usage_limit_reached,
|
|
8
|
+
:after_plan_changed,
|
|
9
|
+
:after_payment_request_changed
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@after_entitlement_changed = nil
|
|
13
|
+
@after_usage_limit_reached = nil
|
|
14
|
+
@after_plan_changed = nil
|
|
15
|
+
@after_payment_request_changed = nil
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace RSB::Entitlements
|
|
7
|
+
|
|
8
|
+
# FIXME: Move admin integration to separate gem!
|
|
9
|
+
# Exclude admin controllers from autoloading when rsb-admin is not present.
|
|
10
|
+
# These controllers inherit from RSB::Admin::AdminController and can only
|
|
11
|
+
# be loaded when rsb-admin is in the bundle.
|
|
12
|
+
initializer 'rsb_entitlements.exclude_admin_controllers', before: :set_autoload_paths do
|
|
13
|
+
unless defined?(RSB::Admin::Engine)
|
|
14
|
+
Rails.autoloaders.main.ignore(root.join('app', 'controllers', 'rsb', 'entitlements', 'admin'))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Register settings schema with rsb-settings
|
|
19
|
+
initializer 'rsb_entitlements.register_settings', after: 'rsb_settings.ready' do
|
|
20
|
+
RSB::Settings.registry.register(RSB::Entitlements.settings_schema)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Signal readiness — provider extensions hook in after this
|
|
24
|
+
initializer 'rsb_entitlements.ready' do
|
|
25
|
+
# Register built-in providers
|
|
26
|
+
RSB::Entitlements.providers.register(RSB::Entitlements::PaymentProvider::Wire)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Load i18n translations for admin labels
|
|
30
|
+
initializer 'rsb_entitlements.i18n' do
|
|
31
|
+
config.i18n.load_path += Dir[RSB::Entitlements::Engine.root.join('config', 'locales', '**', '*.yml')]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Admin integration via lazy on_load hook
|
|
35
|
+
# Registers Plan and Entitlement resources with explicit columns, filters, and form fields,
|
|
36
|
+
# plus a UsageCounters page with custom actions for monitoring and resetting counters.
|
|
37
|
+
initializer 'rsb_entitlements.admin_hooks' do
|
|
38
|
+
ActiveSupport.on_load(:rsb_admin) do |admin_registry|
|
|
39
|
+
admin_registry.register_category 'Billing' do
|
|
40
|
+
resource RSB::Entitlements::Plan,
|
|
41
|
+
icon: 'credit-card',
|
|
42
|
+
actions: %i[index show new create edit update destroy],
|
|
43
|
+
controller: 'rsb/entitlements/admin/plans',
|
|
44
|
+
default_sort: { column: :name, direction: :asc } do
|
|
45
|
+
column :id, link: true
|
|
46
|
+
column :name, sortable: true
|
|
47
|
+
column :slug, visible_on: [:show]
|
|
48
|
+
column :interval, formatter: :badge
|
|
49
|
+
column :price_cents, label: 'Price', sortable: true
|
|
50
|
+
column :currency, visible_on: [:show]
|
|
51
|
+
column :active, formatter: :badge
|
|
52
|
+
column :features, formatter: :json, visible_on: [:show]
|
|
53
|
+
column :limits, formatter: :json, visible_on: [:show]
|
|
54
|
+
column :metadata, formatter: :json, visible_on: [:show]
|
|
55
|
+
column :created_at, formatter: :datetime, visible_on: [:show]
|
|
56
|
+
|
|
57
|
+
filter :active, type: :boolean
|
|
58
|
+
filter :interval, type: :select, options: %w[monthly yearly one_time]
|
|
59
|
+
|
|
60
|
+
form_field :name, type: :text, required: true
|
|
61
|
+
form_field :slug, type: :text, required: true, hint: 'URL-friendly identifier'
|
|
62
|
+
form_field :interval, type: :select, options: %w[monthly yearly one_time], required: true
|
|
63
|
+
form_field :price_cents, type: :number, required: true, label: 'Price (cents)'
|
|
64
|
+
form_field :currency, type: :text, hint: 'ISO 4217 code (e.g., USD)'
|
|
65
|
+
form_field :active, type: :checkbox
|
|
66
|
+
form_field :features, type: :json, hint: 'JSON object of feature flags'
|
|
67
|
+
form_field :limits, type: :json, hint: 'JSON object of usage limits'
|
|
68
|
+
form_field :metadata, type: :json, hint: 'Arbitrary metadata'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
resource RSB::Entitlements::Entitlement,
|
|
72
|
+
icon: 'shield',
|
|
73
|
+
actions: %i[index show grant revoke activate] do
|
|
74
|
+
column :id, link: true
|
|
75
|
+
column :plan_id, label: 'Plan'
|
|
76
|
+
column :entitleable_type, label: 'Type'
|
|
77
|
+
column :entitleable_id, label: 'Owner ID'
|
|
78
|
+
column :status, formatter: :badge
|
|
79
|
+
column :starts_at, formatter: :datetime, visible_on: [:show]
|
|
80
|
+
column :ends_at, formatter: :datetime, visible_on: [:show]
|
|
81
|
+
column :created_at, formatter: :datetime, visible_on: [:show]
|
|
82
|
+
|
|
83
|
+
filter :status, type: :select, options: %w[active expired cancelled]
|
|
84
|
+
filter :entitleable_type, type: :text
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
resource RSB::Entitlements::PaymentRequest,
|
|
88
|
+
icon: 'receipt',
|
|
89
|
+
actions: %i[index show approve reject refund],
|
|
90
|
+
controller: 'rsb/entitlements/admin/payment_requests',
|
|
91
|
+
default_sort: { column: :created_at, direction: :desc },
|
|
92
|
+
per_page: 20 do
|
|
93
|
+
column :id, link: true
|
|
94
|
+
column :requestable_type, label: 'Type'
|
|
95
|
+
column :requestable_id, label: 'Owner ID'
|
|
96
|
+
column :plan_id, label: 'Plan'
|
|
97
|
+
column :provider_key, formatter: :badge
|
|
98
|
+
column :status, formatter: :badge
|
|
99
|
+
column :amount_cents, label: 'Amount'
|
|
100
|
+
column :currency
|
|
101
|
+
column :created_at, formatter: :datetime, visible_on: %i[index show]
|
|
102
|
+
column :provider_ref, visible_on: [:show]
|
|
103
|
+
column :resolved_by, visible_on: [:show]
|
|
104
|
+
column :resolved_at, formatter: :datetime, visible_on: [:show]
|
|
105
|
+
column :admin_note, visible_on: [:show]
|
|
106
|
+
column :expires_at, formatter: :datetime, visible_on: [:show]
|
|
107
|
+
|
|
108
|
+
filter :status, type: :select,
|
|
109
|
+
options: RSB::Entitlements::PaymentRequest::STATUSES
|
|
110
|
+
filter :provider_key, type: :select,
|
|
111
|
+
options: -> { RSB::Entitlements.providers.all.map { |d| d.key.to_s } }
|
|
112
|
+
filter :requestable_type, type: :text
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
page :usage_counters,
|
|
116
|
+
label: 'Usage Monitoring',
|
|
117
|
+
icon: 'bar-chart',
|
|
118
|
+
controller: 'rsb/entitlements/admin/usage_counters',
|
|
119
|
+
actions: [
|
|
120
|
+
{ key: :index, label: 'Overview' },
|
|
121
|
+
{ key: :trend, label: 'Trend' }
|
|
122
|
+
]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
config.generators do |g|
|
|
128
|
+
g.test_framework :minitest, fixture: false
|
|
129
|
+
g.assets false
|
|
130
|
+
g.helper false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
module PaymentProvider
|
|
6
|
+
# Abstract base class for payment providers.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses MUST override these class methods:
|
|
9
|
+
# - self.provider_key -> Symbol
|
|
10
|
+
# - self.provider_label -> String
|
|
11
|
+
#
|
|
12
|
+
# Subclasses MUST override these instance methods:
|
|
13
|
+
# - initiate! -> Hash
|
|
14
|
+
# - complete!(params) -> void
|
|
15
|
+
# - reject!(params) -> void
|
|
16
|
+
#
|
|
17
|
+
# Subclasses MAY override:
|
|
18
|
+
# - self.manual_resolution? -> Boolean (default: false)
|
|
19
|
+
# - self.admin_actions -> Array<Symbol> (default: [])
|
|
20
|
+
# - self.refundable? -> Boolean (default: false)
|
|
21
|
+
# - self.required_settings -> Array<Symbol> (default: [])
|
|
22
|
+
# - refund!(params) -> void (default: raises NotImplementedError)
|
|
23
|
+
# - admin_details -> Hash (default: {})
|
|
24
|
+
#
|
|
25
|
+
# @example Defining a provider
|
|
26
|
+
# class MyProvider < RSB::Entitlements::PaymentProvider::Base
|
|
27
|
+
# def self.provider_key = :my_provider
|
|
28
|
+
# def self.provider_label = "My Provider"
|
|
29
|
+
#
|
|
30
|
+
# settings_schema do
|
|
31
|
+
# setting :api_key, type: :string, default: ""
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# def initiate!
|
|
35
|
+
# { redirect_url: "https://..." }
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# def complete!(params = {})
|
|
39
|
+
# # finalize payment
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# def reject!(params = {})
|
|
43
|
+
# # handle rejection
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
class Base
|
|
47
|
+
attr_reader :payment_request
|
|
48
|
+
|
|
49
|
+
# @param payment_request [RSB::Entitlements::PaymentRequest]
|
|
50
|
+
def initialize(payment_request)
|
|
51
|
+
@payment_request = payment_request
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Class methods (override in subclass) ---
|
|
55
|
+
|
|
56
|
+
# @return [Symbol] unique provider key
|
|
57
|
+
def self.provider_key
|
|
58
|
+
raise NotImplementedError, "#{name} must implement self.provider_key"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String] human-readable label
|
|
62
|
+
def self.provider_label
|
|
63
|
+
raise NotImplementedError, "#{name} must implement self.provider_label"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] whether admin must manually approve/reject
|
|
67
|
+
def self.manual_resolution?
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Array<Symbol>] actions admin can take (e.g., [:approve, :reject])
|
|
72
|
+
def self.admin_actions
|
|
73
|
+
[]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] whether approved requests can be refunded
|
|
77
|
+
def self.refundable?
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Array<Symbol>] settings keys that must have non-default values at registration
|
|
82
|
+
def self.required_settings
|
|
83
|
+
[]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Declare provider-specific settings schema.
|
|
87
|
+
# The block receives the RSB::Settings::Schema DSL.
|
|
88
|
+
# An `enabled` setting is auto-added by the registry.
|
|
89
|
+
#
|
|
90
|
+
# @example
|
|
91
|
+
# settings_schema do
|
|
92
|
+
# setting :api_key, type: :string, default: ""
|
|
93
|
+
# end
|
|
94
|
+
def self.settings_schema(&block)
|
|
95
|
+
if block_given?
|
|
96
|
+
@settings_schema_block = block
|
|
97
|
+
else
|
|
98
|
+
@settings_schema_block
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# --- Instance methods (override in subclass) ---
|
|
103
|
+
|
|
104
|
+
# Start the payment flow. Called after PaymentRequest creation.
|
|
105
|
+
#
|
|
106
|
+
# @return [Hash] one of:
|
|
107
|
+
# - { redirect_url: "..." } for redirect-based flows
|
|
108
|
+
# - { instructions: "..." } for instruction-based flows
|
|
109
|
+
# - { status: :completed } for instant flows
|
|
110
|
+
def initiate!
|
|
111
|
+
raise NotImplementedError, "#{self.class.name} must implement #initiate!"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Finalize a successful payment. Called on admin approval or webhook confirmation.
|
|
115
|
+
#
|
|
116
|
+
# @param params [Hash] provider-specific completion params
|
|
117
|
+
# @return [void]
|
|
118
|
+
def complete!(params = {})
|
|
119
|
+
raise NotImplementedError, "#{self.class.name} must implement #complete!"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Reject a payment. Called on admin rejection.
|
|
123
|
+
#
|
|
124
|
+
# @param params [Hash] provider-specific rejection params
|
|
125
|
+
# @return [void]
|
|
126
|
+
def reject!(params = {})
|
|
127
|
+
raise NotImplementedError, "#{self.class.name} must implement #reject!"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Refund an approved payment. Only called if refundable? is true.
|
|
131
|
+
#
|
|
132
|
+
# @param params [Hash] provider-specific refund params
|
|
133
|
+
# @return [void]
|
|
134
|
+
# @raise [NotImplementedError] if provider does not support refunds
|
|
135
|
+
def refund!(params = {})
|
|
136
|
+
raise NotImplementedError, "#{self.class.name} does not support refunds"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Provider-specific details for the admin show page.
|
|
140
|
+
#
|
|
141
|
+
# @return [Hash] { "Label" => "value", ... }
|
|
142
|
+
def admin_details
|
|
143
|
+
{}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
module PaymentProvider
|
|
6
|
+
# Built-in wire transfer provider. Implements a manual approval flow:
|
|
7
|
+
# 1. initiate! stores bank details and returns payment instructions
|
|
8
|
+
# 2. Admin approves/rejects via admin panel
|
|
9
|
+
# 3. complete! grants the entitlement, reject! marks as rejected
|
|
10
|
+
#
|
|
11
|
+
# Settings (under entitlements.providers.wire.*):
|
|
12
|
+
# - enabled (boolean, default: true)
|
|
13
|
+
# - instructions (string, default: "") — custom instructions template
|
|
14
|
+
# - bank_name (string, default: "")
|
|
15
|
+
# - account_number (string, default: "")
|
|
16
|
+
# - routing_number (string, default: "")
|
|
17
|
+
# - auto_expire_hours (integer, default: 72)
|
|
18
|
+
class Wire < Base
|
|
19
|
+
def self.provider_key
|
|
20
|
+
:wire
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.provider_label
|
|
24
|
+
'Wire Transfer'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.manual_resolution?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.admin_actions
|
|
32
|
+
%i[approve reject]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.refundable?
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
settings_schema do
|
|
40
|
+
setting :instructions,
|
|
41
|
+
type: :string,
|
|
42
|
+
default: '',
|
|
43
|
+
depends_on: 'entitlements.providers.wire.enabled',
|
|
44
|
+
description: 'Custom payment instructions template (supports %<amount>s, %<currency>s, %<bank_name>s, %<account_number>s, %<routing_number>s)'
|
|
45
|
+
|
|
46
|
+
setting :bank_name,
|
|
47
|
+
type: :string,
|
|
48
|
+
default: '',
|
|
49
|
+
depends_on: 'entitlements.providers.wire.enabled',
|
|
50
|
+
description: 'Bank name for wire transfer details'
|
|
51
|
+
|
|
52
|
+
setting :account_number,
|
|
53
|
+
type: :string,
|
|
54
|
+
default: '',
|
|
55
|
+
depends_on: 'entitlements.providers.wire.enabled',
|
|
56
|
+
description: 'Account number for wire transfer'
|
|
57
|
+
|
|
58
|
+
setting :routing_number,
|
|
59
|
+
type: :string,
|
|
60
|
+
default: '',
|
|
61
|
+
depends_on: 'entitlements.providers.wire.enabled',
|
|
62
|
+
description: 'Routing number for wire transfer'
|
|
63
|
+
|
|
64
|
+
setting :auto_expire_hours,
|
|
65
|
+
type: :integer,
|
|
66
|
+
default: 72,
|
|
67
|
+
depends_on: 'entitlements.providers.wire.enabled',
|
|
68
|
+
description: 'Hours before wire transfer requests auto-expire'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Start the wire transfer flow.
|
|
72
|
+
# Stores bank details in provider_data, sets expiry, transitions to processing.
|
|
73
|
+
#
|
|
74
|
+
# @return [Hash] { instructions: "Please wire $X to..." }
|
|
75
|
+
def initiate!
|
|
76
|
+
bank_name = setting('bank_name')
|
|
77
|
+
account_number = setting('account_number')
|
|
78
|
+
routing_number = setting('routing_number')
|
|
79
|
+
auto_expire_hours = setting('auto_expire_hours').to_i
|
|
80
|
+
custom_instructions = setting('instructions')
|
|
81
|
+
|
|
82
|
+
payment_request.update!(
|
|
83
|
+
status: 'processing',
|
|
84
|
+
expires_at: auto_expire_hours.hours.from_now,
|
|
85
|
+
provider_data: {
|
|
86
|
+
'bank_name' => bank_name,
|
|
87
|
+
'account_number' => account_number,
|
|
88
|
+
'routing_number' => routing_number,
|
|
89
|
+
'instructions_sent_at' => Time.current.iso8601
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
amount = format_amount(payment_request.amount_cents, payment_request.currency)
|
|
94
|
+
instructions = build_instructions(
|
|
95
|
+
custom_instructions,
|
|
96
|
+
amount: amount,
|
|
97
|
+
currency: payment_request.currency,
|
|
98
|
+
bank_name: bank_name,
|
|
99
|
+
account_number: account_number,
|
|
100
|
+
routing_number: routing_number
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
{ instructions: instructions }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Approve the wire transfer. Grants entitlement to the requestable.
|
|
107
|
+
#
|
|
108
|
+
# @param params [Hash] unused
|
|
109
|
+
# @return [void]
|
|
110
|
+
def complete!(_params = {})
|
|
111
|
+
return unless payment_request.actionable?
|
|
112
|
+
|
|
113
|
+
entitlement = payment_request.requestable.grant_entitlement(
|
|
114
|
+
plan: payment_request.plan,
|
|
115
|
+
provider: payment_request.provider_key,
|
|
116
|
+
metadata: payment_request.metadata
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
payment_request.update!(
|
|
120
|
+
status: 'approved',
|
|
121
|
+
entitlement: entitlement
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Reject the wire transfer. No entitlement changes.
|
|
126
|
+
#
|
|
127
|
+
# @param params [Hash] unused
|
|
128
|
+
# @return [void]
|
|
129
|
+
def reject!(_params = {})
|
|
130
|
+
return unless payment_request.actionable?
|
|
131
|
+
|
|
132
|
+
payment_request.update!(status: 'rejected')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Provider-specific details for admin show page.
|
|
136
|
+
#
|
|
137
|
+
# @return [Hash] { "Bank Name" => "...", ... }
|
|
138
|
+
def admin_details
|
|
139
|
+
data = payment_request.provider_data || {}
|
|
140
|
+
details = {}
|
|
141
|
+
details['Bank Name'] = data['bank_name'] if data['bank_name'].present?
|
|
142
|
+
details['Account Number'] = data['account_number'] if data['account_number'].present?
|
|
143
|
+
details['Routing Number'] = data['routing_number'] if data['routing_number'].present?
|
|
144
|
+
details['Instructions Sent At'] = data['instructions_sent_at'] if data['instructions_sent_at'].present?
|
|
145
|
+
details
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Read a wire provider setting.
|
|
151
|
+
#
|
|
152
|
+
# @param key [String] setting key (without provider prefix)
|
|
153
|
+
# @return [Object] the setting value
|
|
154
|
+
def setting(key)
|
|
155
|
+
RSB::Settings.get("entitlements.providers.wire.#{key}")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Format cents into a human-readable amount string.
|
|
159
|
+
#
|
|
160
|
+
# @param cents [Integer]
|
|
161
|
+
# @param currency [String]
|
|
162
|
+
# @return [String] e.g., "$99.00"
|
|
163
|
+
def format_amount(cents, currency)
|
|
164
|
+
dollars = cents / 100.0
|
|
165
|
+
symbol = currency.upcase == 'USD' ? '$' : currency.upcase
|
|
166
|
+
"#{symbol}#{'%.2f' % dollars}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Build instructions string from template or default.
|
|
170
|
+
#
|
|
171
|
+
# @param template [String] custom template (may be blank)
|
|
172
|
+
# @param kwargs [Hash] substitution variables
|
|
173
|
+
# @return [String]
|
|
174
|
+
def build_instructions(template, **kwargs)
|
|
175
|
+
if template.present?
|
|
176
|
+
template % kwargs
|
|
177
|
+
else
|
|
178
|
+
parts = ["Please wire #{kwargs[:amount]} to"]
|
|
179
|
+
parts << kwargs[:bank_name] if kwargs[:bank_name].present?
|
|
180
|
+
parts << "(Account: #{kwargs[:account_number]})" if kwargs[:account_number].present?
|
|
181
|
+
parts << "(Routing: #{kwargs[:routing_number]})" if kwargs[:routing_number].present?
|
|
182
|
+
parts.join(' ')
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|