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
@@ -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