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,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class Entitlement < ApplicationRecord
|
|
6
|
+
STATUSES = %w[pending active expired revoked].freeze
|
|
7
|
+
REVOKE_REASONS = %w[refund admin chargeback non_renewal upgrade].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :entitleable, polymorphic: true
|
|
10
|
+
belongs_to :plan
|
|
11
|
+
|
|
12
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
13
|
+
validates :provider, presence: true,
|
|
14
|
+
inclusion: {
|
|
15
|
+
in: ->(_) { RSB::Entitlements.providers.keys.map(&:to_s) },
|
|
16
|
+
message: 'is not a registered provider'
|
|
17
|
+
}
|
|
18
|
+
validates :revoke_reason, inclusion: { in: REVOKE_REASONS }, allow_nil: true
|
|
19
|
+
|
|
20
|
+
scope :active, -> { where(status: 'active') }
|
|
21
|
+
scope :current, -> { where(status: %w[pending active]) }
|
|
22
|
+
|
|
23
|
+
after_commit :fire_changed_callback, if: :saved_change_to_status?
|
|
24
|
+
after_commit :create_usage_counters, on: %i[create update], if: :active?
|
|
25
|
+
|
|
26
|
+
def activate!
|
|
27
|
+
update!(status: 'active', activated_at: Time.current)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def expire!
|
|
31
|
+
update!(status: 'expired')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def revoke!(reason:)
|
|
35
|
+
update!(status: 'revoked', revoked_at: Time.current, revoke_reason: reason)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def active?
|
|
39
|
+
status == 'active'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def expired?
|
|
43
|
+
status == 'expired'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def revoked?
|
|
47
|
+
status == 'revoked'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def pending?
|
|
51
|
+
status == 'pending'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def fire_changed_callback
|
|
57
|
+
callback = RSB::Entitlements.configuration.after_entitlement_changed
|
|
58
|
+
callback&.call(self)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_usage_counters
|
|
62
|
+
return unless saved_change_to_status? && status == 'active'
|
|
63
|
+
|
|
64
|
+
UsageCounterService.new.create_counters_for(self)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class PaymentRequest < ApplicationRecord
|
|
6
|
+
STATUSES = %w[pending processing approved rejected expired refunded].freeze
|
|
7
|
+
ACTIONABLE_STATUSES = %w[pending processing].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :requestable, polymorphic: true
|
|
10
|
+
belongs_to :plan
|
|
11
|
+
belongs_to :entitlement, optional: true
|
|
12
|
+
|
|
13
|
+
validates :provider_key, presence: true,
|
|
14
|
+
inclusion: {
|
|
15
|
+
in: ->(_) { RSB::Entitlements.providers.keys.map(&:to_s) },
|
|
16
|
+
message: 'is not a registered provider'
|
|
17
|
+
}
|
|
18
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
19
|
+
validates :amount_cents, numericality: { greater_than_or_equal_to: 0 }
|
|
20
|
+
validates :currency, presence: true
|
|
21
|
+
|
|
22
|
+
scope :actionable, -> { where(status: ACTIONABLE_STATUSES) }
|
|
23
|
+
scope :for_provider, ->(key) { where(provider_key: key.to_s) }
|
|
24
|
+
|
|
25
|
+
after_commit :fire_changed_callback, if: :saved_change_to_status?
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true if status is "pending"
|
|
28
|
+
def pending?
|
|
29
|
+
status == 'pending'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] true if status is "processing"
|
|
33
|
+
def processing?
|
|
34
|
+
status == 'processing'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] true if status is "approved"
|
|
38
|
+
def approved?
|
|
39
|
+
status == 'approved'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Boolean] true if status is "rejected"
|
|
43
|
+
def rejected?
|
|
44
|
+
status == 'rejected'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] true if status is "expired"
|
|
48
|
+
def expired?
|
|
49
|
+
status == 'expired'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] true if status is "refunded"
|
|
53
|
+
def refunded?
|
|
54
|
+
status == 'refunded'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] true if status is "pending" or "processing"
|
|
58
|
+
def actionable?
|
|
59
|
+
ACTIONABLE_STATUSES.include?(status)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def fire_changed_callback
|
|
65
|
+
callback = RSB::Entitlements.configuration.after_payment_request_changed
|
|
66
|
+
callback&.call(self)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class Plan < ApplicationRecord
|
|
6
|
+
INTERVALS = %w[monthly yearly lifetime one_time].freeze
|
|
7
|
+
|
|
8
|
+
has_many :entitlements, dependent: :restrict_with_error
|
|
9
|
+
has_many :usage_counters, dependent: :restrict_with_error
|
|
10
|
+
|
|
11
|
+
validates :name, presence: true
|
|
12
|
+
validates :slug, presence: true,
|
|
13
|
+
uniqueness: true,
|
|
14
|
+
format: { with: /\A[a-z0-9_-]+\z/ }
|
|
15
|
+
validates :interval, presence: true, inclusion: { in: INTERVALS }
|
|
16
|
+
validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
17
|
+
validates :currency, presence: true
|
|
18
|
+
|
|
19
|
+
scope :active, -> { where(active: true) }
|
|
20
|
+
|
|
21
|
+
def free?
|
|
22
|
+
price_cents.zero?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def feature?(key)
|
|
26
|
+
features[key.to_s] == true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the integer limit value for a metric from the nested limits config.
|
|
30
|
+
#
|
|
31
|
+
# @param key [String, Symbol] The metric key (e.g., "api_calls")
|
|
32
|
+
# @return [Integer, nil] The limit value, or nil if undefined or unlimited
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# plan.limit_for("api_calls") # => 1000
|
|
36
|
+
# plan.limit_for(:projects) # => 10
|
|
37
|
+
# plan.limit_for("undefined") # => nil
|
|
38
|
+
def limit_for(key)
|
|
39
|
+
config = limits[key.to_s]
|
|
40
|
+
return nil unless config.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
config['limit']
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the period type for a metric from the nested limits config.
|
|
46
|
+
#
|
|
47
|
+
# @param key [String, Symbol] The metric key (e.g., "api_calls")
|
|
48
|
+
# @return [String, nil] The period type ("daily", "weekly", "monthly"), or nil for cumulative
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# plan.period_for("api_calls") # => "daily"
|
|
52
|
+
# plan.period_for(:projects) # => nil (cumulative)
|
|
53
|
+
# plan.period_for("undefined") # => nil
|
|
54
|
+
def period_for(key)
|
|
55
|
+
config = limits[key.to_s]
|
|
56
|
+
return nil unless config.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
config['period']
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the full limit configuration hash for a metric.
|
|
62
|
+
#
|
|
63
|
+
# @param key [String, Symbol] The metric key (e.g., "api_calls")
|
|
64
|
+
# @return [Hash, nil] The full config hash with "limit" and "period" keys, or nil if undefined
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# plan.limit_config_for("api_calls")
|
|
68
|
+
# # => { "limit" => 1000, "period" => "daily" }
|
|
69
|
+
#
|
|
70
|
+
# plan.limit_config_for("projects")
|
|
71
|
+
# # => { "limit" => 10, "period" => nil }
|
|
72
|
+
#
|
|
73
|
+
# plan.limit_config_for("undefined")
|
|
74
|
+
# # => nil
|
|
75
|
+
def limit_config_for(key)
|
|
76
|
+
config = limits[key.to_s]
|
|
77
|
+
return nil unless config.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
config
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class UsageCounter < ApplicationRecord
|
|
6
|
+
belongs_to :countable, polymorphic: true
|
|
7
|
+
belongs_to :plan, class_name: 'RSB::Entitlements::Plan'
|
|
8
|
+
|
|
9
|
+
validates :metric, presence: true,
|
|
10
|
+
uniqueness: { scope: %i[countable_type countable_id period_key plan_id] }
|
|
11
|
+
validates :period_key, presence: true
|
|
12
|
+
validates :current_value, numericality: { greater_than_or_equal_to: 0 }
|
|
13
|
+
|
|
14
|
+
scope :for_metric, ->(metric) { where(metric: metric.to_s) }
|
|
15
|
+
scope :for_period, ->(period_key) { where(period_key: period_key.to_s) }
|
|
16
|
+
scope :for_plan, ->(plan) { where(plan_id: plan.id) }
|
|
17
|
+
scope :cumulative, -> { where(period_key: PeriodKeyCalculator::CUMULATIVE_KEY) }
|
|
18
|
+
scope :recent, ->(n) { order(period_key: :desc).limit(n) }
|
|
19
|
+
|
|
20
|
+
# Atomically increments the counter's current_value.
|
|
21
|
+
#
|
|
22
|
+
# Uses SQL UPDATE to ensure atomicity under concurrent access.
|
|
23
|
+
# Fires the `after_usage_limit_reached` callback if the counter reaches its limit.
|
|
24
|
+
#
|
|
25
|
+
# @param amount [Integer] the amount to increment by (default: 1)
|
|
26
|
+
# @return [Integer] the new current_value after increment
|
|
27
|
+
def increment!(amount = 1)
|
|
28
|
+
self.class.where(id: id).update_all(
|
|
29
|
+
['current_value = current_value + ?', amount]
|
|
30
|
+
)
|
|
31
|
+
reload
|
|
32
|
+
check_limit_reached
|
|
33
|
+
current_value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns whether the counter has reached its limit.
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean] true if current_value >= limit, false if no limit set
|
|
39
|
+
def at_limit?
|
|
40
|
+
return false if limit.nil?
|
|
41
|
+
|
|
42
|
+
current_value >= limit
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the remaining quota before the limit is reached.
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer, nil] remaining count, or nil if no limit (unlimited)
|
|
48
|
+
def remaining
|
|
49
|
+
return nil if limit.nil?
|
|
50
|
+
|
|
51
|
+
[limit - current_value, 0].max
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def check_limit_reached
|
|
57
|
+
return unless at_limit?
|
|
58
|
+
|
|
59
|
+
callback = RSB::Entitlements.configuration.after_usage_limit_reached
|
|
60
|
+
callback&.call(self)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Entitlements
|
|
5
|
+
class UsageCounterService
|
|
6
|
+
# Creates usage counters for a newly activated entitlement.
|
|
7
|
+
#
|
|
8
|
+
# For each metric in the plan's limits, creates a counter with the correct
|
|
9
|
+
# period_key and limit. Uses find_or_create_by! to avoid duplicates.
|
|
10
|
+
#
|
|
11
|
+
# @param entitlement [RSB::Entitlements::Entitlement] the activated entitlement
|
|
12
|
+
# @return [void]
|
|
13
|
+
def create_counters_for(entitlement)
|
|
14
|
+
return unless entitlement.active?
|
|
15
|
+
return unless RSB::Settings.get('entitlements.auto_create_counters')
|
|
16
|
+
|
|
17
|
+
plan = entitlement.plan
|
|
18
|
+
return if plan.limits.blank?
|
|
19
|
+
|
|
20
|
+
plan.limits.each do |metric, config|
|
|
21
|
+
next unless config.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
period = config['period']
|
|
24
|
+
limit_value = config['limit']
|
|
25
|
+
period_key = PeriodKeyCalculator.current_key(period)
|
|
26
|
+
|
|
27
|
+
entitlement.entitleable.usage_counters.find_or_create_by!(
|
|
28
|
+
metric: metric.to_s,
|
|
29
|
+
period_key: period_key,
|
|
30
|
+
plan_id: plan.id
|
|
31
|
+
) do |counter|
|
|
32
|
+
counter.limit = limit_value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Handles usage counter transitions when an entitleable changes plans.
|
|
38
|
+
#
|
|
39
|
+
# For each metric in the new plan:
|
|
40
|
+
# - If the period type changed: creates a fresh counter (value: 0)
|
|
41
|
+
# - If the period type is the same:
|
|
42
|
+
# - "continue" mode: carries over current_value from old counter
|
|
43
|
+
# - "reset" mode: creates counter with value: 0
|
|
44
|
+
# - New metrics get a fresh counter
|
|
45
|
+
# - Removed metrics: old counters are left as historical records
|
|
46
|
+
#
|
|
47
|
+
# @param entitleable [Object] the entitleable record (e.g., Organization)
|
|
48
|
+
# @param old_plan [RSB::Entitlements::Plan] the previous plan
|
|
49
|
+
# @param new_plan [RSB::Entitlements::Plan] the new plan
|
|
50
|
+
# @return [void]
|
|
51
|
+
def handle_plan_change(entitleable, old_plan:, new_plan:)
|
|
52
|
+
mode = begin
|
|
53
|
+
RSB::Settings.get('entitlements.on_plan_change_usage')
|
|
54
|
+
rescue StandardError
|
|
55
|
+
'continue'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
source = entitleable.respond_to?(:entitlement_source) ? entitleable.entitlement_source : entitleable
|
|
59
|
+
|
|
60
|
+
new_plan.limits.each do |metric, new_config|
|
|
61
|
+
next unless new_config.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
new_period = new_config['period']
|
|
64
|
+
new_limit = new_config['limit']
|
|
65
|
+
new_period_key = PeriodKeyCalculator.current_key(new_period)
|
|
66
|
+
|
|
67
|
+
old_config = old_plan.limit_config_for(metric)
|
|
68
|
+
old_period = old_config&.dig('period')
|
|
69
|
+
|
|
70
|
+
# Determine carry-over value
|
|
71
|
+
carry_over = 0
|
|
72
|
+
if old_config && old_period == new_period && mode == 'continue'
|
|
73
|
+
old_period_key = PeriodKeyCalculator.current_key(old_period)
|
|
74
|
+
old_counter = source.usage_counters
|
|
75
|
+
.for_metric(metric)
|
|
76
|
+
.for_period(old_period_key)
|
|
77
|
+
.for_plan(old_plan)
|
|
78
|
+
.last
|
|
79
|
+
carry_over = old_counter&.current_value || 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
counter = source.usage_counters.find_or_initialize_by(
|
|
83
|
+
metric: metric.to_s,
|
|
84
|
+
period_key: new_period_key,
|
|
85
|
+
plan_id: new_plan.id
|
|
86
|
+
)
|
|
87
|
+
counter.current_value = carry_over
|
|
88
|
+
counter.limit = new_limit
|
|
89
|
+
counter.save!
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold">Payment Requests</h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg p-4 mb-4">
|
|
6
|
+
<form method="get" action="/admin/payment_requests" class="flex flex-wrap items-end gap-4">
|
|
7
|
+
<div>
|
|
8
|
+
<label class="block text-xs font-medium text-rsb-muted mb-1">Status</label>
|
|
9
|
+
<%= select_tag :status,
|
|
10
|
+
options_for_select(
|
|
11
|
+
[["All", ""]] + RSB::Entitlements::PaymentRequest::STATUSES.map { |s| [s.titleize, s] },
|
|
12
|
+
params[:status]
|
|
13
|
+
),
|
|
14
|
+
class: "px-3 py-1.5 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
15
|
+
</div>
|
|
16
|
+
<div>
|
|
17
|
+
<label class="block text-xs font-medium text-rsb-muted mb-1">Provider</label>
|
|
18
|
+
<%= select_tag :provider_key,
|
|
19
|
+
options_for_select(
|
|
20
|
+
[["All", ""]] + RSB::Entitlements.providers.all.map { |d| [d.label, d.key.to_s] },
|
|
21
|
+
params[:provider_key]
|
|
22
|
+
),
|
|
23
|
+
class: "px-3 py-1.5 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex gap-2">
|
|
26
|
+
<button type="submit"
|
|
27
|
+
class="px-4 py-1.5 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover">
|
|
28
|
+
Filter
|
|
29
|
+
</button>
|
|
30
|
+
<a href="/admin/payment_requests"
|
|
31
|
+
class="px-4 py-1.5 border border-rsb-border text-rsb-muted rounded-rsb text-sm hover:bg-rsb-bg">
|
|
32
|
+
Clear
|
|
33
|
+
</a>
|
|
34
|
+
</div>
|
|
35
|
+
</form>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm overflow-hidden">
|
|
39
|
+
<% if @payment_requests.any? %>
|
|
40
|
+
<div class="overflow-x-auto">
|
|
41
|
+
<table class="w-full">
|
|
42
|
+
<thead>
|
|
43
|
+
<tr>
|
|
44
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">ID</th>
|
|
45
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Type</th>
|
|
46
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Owner ID</th>
|
|
47
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Plan</th>
|
|
48
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Provider</th>
|
|
49
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Status</th>
|
|
50
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Amount</th>
|
|
51
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Currency</th>
|
|
52
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Created</th>
|
|
53
|
+
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border"></th>
|
|
54
|
+
</tr>
|
|
55
|
+
</thead>
|
|
56
|
+
<tbody>
|
|
57
|
+
<% @payment_requests.each do |request| %>
|
|
58
|
+
<tr class="hover:bg-rsb-bg border-b border-rsb-border last:border-b-0">
|
|
59
|
+
<td class="px-4 py-3 text-sm">
|
|
60
|
+
<a href="/admin/payment_requests/<%= request.id %>" class="text-rsb-primary hover:underline"><%= request.id %></a>
|
|
61
|
+
</td>
|
|
62
|
+
<td class="px-4 py-3 text-sm text-rsb-muted"><%= request.requestable_type.demodulize %></td>
|
|
63
|
+
<td class="px-4 py-3 text-sm"><%= request.requestable_id %></td>
|
|
64
|
+
<td class="px-4 py-3 text-sm"><%= request.plan&.name %></td>
|
|
65
|
+
<td class="px-4 py-3 text-sm"><%= rsb_admin_badge(request.provider_key) %></td>
|
|
66
|
+
<td class="px-4 py-3 text-sm"><%= rsb_admin_badge(request.status) %></td>
|
|
67
|
+
<td class="px-4 py-3 text-sm"><%= request.amount_cents %></td>
|
|
68
|
+
<td class="px-4 py-3 text-sm"><%= request.currency.upcase %></td>
|
|
69
|
+
<td class="px-4 py-3 text-sm text-rsb-muted"><%= request.created_at&.strftime("%Y-%m-%d %H:%M") %></td>
|
|
70
|
+
<td class="px-4 py-3 text-sm text-right">
|
|
71
|
+
<a href="/admin/payment_requests/<%= request.id %>"
|
|
72
|
+
class="inline-flex items-center gap-1 px-3 py-1.5 border border-rsb-border text-rsb-text rounded-rsb text-xs font-medium hover:bg-rsb-bg transition-colors">View</a>
|
|
73
|
+
</td>
|
|
74
|
+
</tr>
|
|
75
|
+
<% end %>
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="flex justify-between items-center px-4 py-3 border-t border-rsb-border">
|
|
81
|
+
<span class="text-sm text-rsb-muted">Page <%= @current_page + 1 %></span>
|
|
82
|
+
<div class="flex gap-2">
|
|
83
|
+
<% if @current_page > 0 %>
|
|
84
|
+
<a href="/admin/payment_requests?page=<%= @current_page - 1 %><%= "&status=#{params[:status]}" if params[:status].present? %><%= "&provider_key=#{params[:provider_key]}" if params[:provider_key].present? %>"
|
|
85
|
+
class="inline-flex items-center px-3 py-1.5 border border-rsb-border text-rsb-text rounded-rsb text-xs font-medium hover:bg-rsb-bg transition-colors">Previous</a>
|
|
86
|
+
<% end %>
|
|
87
|
+
<% if @payment_requests.size == @per_page %>
|
|
88
|
+
<a href="/admin/payment_requests?page=<%= @current_page + 1 %><%= "&status=#{params[:status]}" if params[:status].present? %><%= "&provider_key=#{params[:provider_key]}" if params[:provider_key].present? %>"
|
|
89
|
+
class="inline-flex items-center px-3 py-1.5 border border-rsb-border text-rsb-text rounded-rsb text-xs font-medium hover:bg-rsb-bg transition-colors">Next</a>
|
|
90
|
+
<% end %>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<% else %>
|
|
94
|
+
<div class="p-8 text-center">
|
|
95
|
+
<p class="text-rsb-muted text-sm">No payment requests found.</p>
|
|
96
|
+
</div>
|
|
97
|
+
<% end %>
|
|
98
|
+
</div>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold">Payment Request #<%= @payment_request.id %></h1>
|
|
3
|
+
<div class="flex gap-2">
|
|
4
|
+
<a href="/admin/payment_requests"
|
|
5
|
+
class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border rounded-rsb text-sm hover:bg-rsb-bg transition-colors">Back</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<%# Request Details %>
|
|
10
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
|
|
11
|
+
<h3 class="text-base font-semibold mb-4">Request Information</h3>
|
|
12
|
+
<div class="grid grid-cols-[200px_1fr] gap-4">
|
|
13
|
+
<strong class="text-sm text-rsb-muted">ID</strong>
|
|
14
|
+
<div><%= @payment_request.id %></div>
|
|
15
|
+
|
|
16
|
+
<strong class="text-sm text-rsb-muted">Requestable</strong>
|
|
17
|
+
<div><%= @payment_request.requestable_type.demodulize %> #<%= @payment_request.requestable_id %></div>
|
|
18
|
+
|
|
19
|
+
<strong class="text-sm text-rsb-muted">Plan</strong>
|
|
20
|
+
<div><%= @payment_request.plan&.name %></div>
|
|
21
|
+
|
|
22
|
+
<strong class="text-sm text-rsb-muted">Provider</strong>
|
|
23
|
+
<div><%= rsb_admin_badge(@payment_request.provider_key) %></div>
|
|
24
|
+
|
|
25
|
+
<strong class="text-sm text-rsb-muted">Status</strong>
|
|
26
|
+
<div><%= rsb_admin_badge(@payment_request.status) %></div>
|
|
27
|
+
|
|
28
|
+
<strong class="text-sm text-rsb-muted">Amount</strong>
|
|
29
|
+
<div><%= @payment_request.amount_cents %> <%= @payment_request.currency.upcase %></div>
|
|
30
|
+
|
|
31
|
+
<% if @payment_request.provider_ref.present? %>
|
|
32
|
+
<strong class="text-sm text-rsb-muted">Provider Ref</strong>
|
|
33
|
+
<div><code class="px-1.5 py-0.5 bg-rsb-bg rounded-rsb-sm text-sm"><%= @payment_request.provider_ref %></code></div>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<strong class="text-sm text-rsb-muted">Expires At</strong>
|
|
37
|
+
<div><%= @payment_request.expires_at&.strftime("%Y-%m-%d %H:%M") || "—" %></div>
|
|
38
|
+
|
|
39
|
+
<strong class="text-sm text-rsb-muted">Created At</strong>
|
|
40
|
+
<div><%= @payment_request.created_at&.strftime("%Y-%m-%d %H:%M") %></div>
|
|
41
|
+
|
|
42
|
+
<% if @payment_request.resolved_by.present? %>
|
|
43
|
+
<strong class="text-sm text-rsb-muted">Resolved By</strong>
|
|
44
|
+
<div><%= @payment_request.resolved_by %></div>
|
|
45
|
+
|
|
46
|
+
<strong class="text-sm text-rsb-muted">Resolved At</strong>
|
|
47
|
+
<div><%= @payment_request.resolved_at&.strftime("%Y-%m-%d %H:%M") %></div>
|
|
48
|
+
<% end %>
|
|
49
|
+
|
|
50
|
+
<% if @payment_request.admin_note.present? %>
|
|
51
|
+
<strong class="text-sm text-rsb-muted">Admin Note</strong>
|
|
52
|
+
<div><%= @payment_request.admin_note %></div>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<% if @payment_request.entitlement_id.present? %>
|
|
56
|
+
<strong class="text-sm text-rsb-muted">Entitlement</strong>
|
|
57
|
+
<div>#<%= @payment_request.entitlement_id %></div>
|
|
58
|
+
<% end %>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<%# Provider Details %>
|
|
63
|
+
<% if @provider_details.present? %>
|
|
64
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
|
|
65
|
+
<h3 class="text-base font-semibold mb-4">Provider Details</h3>
|
|
66
|
+
<div class="grid grid-cols-[200px_1fr] gap-4">
|
|
67
|
+
<% @provider_details.each do |label, value| %>
|
|
68
|
+
<strong class="text-sm text-rsb-muted"><%= label %></strong>
|
|
69
|
+
<div><%= value %></div>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<% end %>
|
|
74
|
+
|
|
75
|
+
<%# Provider Data JSON %>
|
|
76
|
+
<% if @payment_request.provider_data.present? && @payment_request.provider_data != {} %>
|
|
77
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
|
|
78
|
+
<h3 class="text-base font-semibold mb-4">Provider Data</h3>
|
|
79
|
+
<pre class="p-4 bg-rsb-bg rounded-rsb text-sm overflow-x-auto"><%= JSON.pretty_generate(@payment_request.provider_data) %></pre>
|
|
80
|
+
</div>
|
|
81
|
+
<% end %>
|
|
82
|
+
|
|
83
|
+
<%# Metadata JSON %>
|
|
84
|
+
<% if @payment_request.metadata.present? && @payment_request.metadata != {} %>
|
|
85
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
|
|
86
|
+
<h3 class="text-base font-semibold mb-4">Metadata</h3>
|
|
87
|
+
<pre class="p-4 bg-rsb-bg rounded-rsb text-sm overflow-x-auto"><%= JSON.pretty_generate(@payment_request.metadata) %></pre>
|
|
88
|
+
</div>
|
|
89
|
+
<% end %>
|
|
90
|
+
|
|
91
|
+
<%# Action Buttons — Approve / Reject %>
|
|
92
|
+
<% if @payment_request.actionable? && @provider_definition&.manual_resolution %>
|
|
93
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
|
|
94
|
+
<h3 class="text-base font-semibold mb-4">Actions</h3>
|
|
95
|
+
<div class="flex flex-wrap items-end gap-4">
|
|
96
|
+
<% if @provider_definition.admin_actions.include?(:approve) %>
|
|
97
|
+
<%= form_tag("/admin/payment_requests/#{@payment_request.id}/approve", method: :patch, class: "inline") do %>
|
|
98
|
+
<button type="submit"
|
|
99
|
+
data-turbo-confirm="Approve this payment request?"
|
|
100
|
+
class="px-4 py-2 bg-rsb-success text-white rounded-rsb text-sm font-medium hover:opacity-90 transition-colors">
|
|
101
|
+
Approve
|
|
102
|
+
</button>
|
|
103
|
+
<% end %>
|
|
104
|
+
<% end %>
|
|
105
|
+
|
|
106
|
+
<% if @provider_definition.admin_actions.include?(:reject) %>
|
|
107
|
+
<%= form_tag("/admin/payment_requests/#{@payment_request.id}/reject", method: :patch, class: "inline-flex items-end gap-2") do %>
|
|
108
|
+
<div>
|
|
109
|
+
<label class="block text-xs font-medium text-rsb-muted mb-1">Note</label>
|
|
110
|
+
<%= text_field_tag :admin_note, "",
|
|
111
|
+
placeholder: "Optional note",
|
|
112
|
+
class: "px-3 py-1.5 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
113
|
+
</div>
|
|
114
|
+
<button type="submit"
|
|
115
|
+
data-turbo-confirm="Reject this payment request?"
|
|
116
|
+
class="px-4 py-2 bg-rsb-danger text-white rounded-rsb text-sm font-medium hover:opacity-90 transition-colors">
|
|
117
|
+
Reject
|
|
118
|
+
</button>
|
|
119
|
+
<% end %>
|
|
120
|
+
<% end %>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<% end %>
|
|
124
|
+
|
|
125
|
+
<%# Refund Button %>
|
|
126
|
+
<% if @payment_request.approved? && @provider_definition&.refundable %>
|
|
127
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
128
|
+
<h3 class="text-base font-semibold text-rsb-danger mb-4">Danger Zone</h3>
|
|
129
|
+
<%= form_tag("/admin/payment_requests/#{@payment_request.id}/refund", method: :patch) do %>
|
|
130
|
+
<button type="submit"
|
|
131
|
+
data-turbo-confirm="Refund this payment request? This will revoke the linked entitlement."
|
|
132
|
+
class="px-4 py-2 bg-rsb-danger text-white rounded-rsb text-sm font-medium hover:opacity-90 transition-colors">
|
|
133
|
+
Refund
|
|
134
|
+
</button>
|
|
135
|
+
<% end %>
|
|
136
|
+
</div>
|
|
137
|
+
<% end %>
|